diff --git a/im/.deploy/admin.sha b/im/.deploy/admin.sha index 221b1ca37..9c06fe8d1 100644 --- a/im/.deploy/admin.sha +++ b/im/.deploy/admin.sha @@ -1 +1 @@ -57dce2e3bd928effae6f9684d54fc4f44a1865a1ee257f92d3912bc96e9ea5fd +fefa866e36c754224a89fa9f096d732bb526fb931113a92364af6c5a9b14b1bd diff --git a/im/.deploy/api.gz b/im/.deploy/api.gz index db6092157..bff55034b 100644 Binary files a/im/.deploy/api.gz and b/im/.deploy/api.gz differ diff --git a/im/.deploy/part-00 b/im/.deploy/part-00 index 60b9969cf..6d7336725 100644 Binary files a/im/.deploy/part-00 and b/im/.deploy/part-00 differ diff --git a/im/.deploy/part-01 b/im/.deploy/part-01 index 9f591f41f..36170f73a 100644 Binary files a/im/.deploy/part-01 and b/im/.deploy/part-01 differ diff --git a/im/.deploy/part-02 b/im/.deploy/part-02 index 9ddb5a64b..71e5afc98 100644 Binary files a/im/.deploy/part-02 and b/im/.deploy/part-02 differ diff --git a/im/.deploy/xingyu-api-new b/im/.deploy/xingyu-api-new index 70339742e..22d8fa88a 100644 Binary files a/im/.deploy/xingyu-api-new and b/im/.deploy/xingyu-api-new differ diff --git a/im/backend/internal/app/admin.go b/im/backend/internal/app/admin.go index c021fd8f3..6d0420829 100644 --- a/im/backend/internal/app/admin.go +++ b/im/backend/internal/app/admin.go @@ -851,9 +851,43 @@ func (a *App) adminUpdateConfig(w http.ResponseWriter, r *http.Request) { } func (a *App) adminAuditLogs(w http.ResponseWriter, r *http.Request) { page, size, offset := pagination(r) + // 审计日志现在记的是真金白银的动作(退款、放款、指定喂养者)。查一条 + // "这笔钱是谁退的"不能靠一页页翻,所以这里按人、动作、对象和时间筛。 + where := " WHERE 1=1" + args := []any{} + if admin := strings.TrimSpace(r.URL.Query().Get("adminId")); admin != "" { + where += " AND l.admin_user_id=?" + args = append(args, admin) + } + if action := strings.TrimSpace(r.URL.Query().Get("action")); action != "" { + where += " AND l.action=?" + args = append(args, action) + } + if targetType := strings.TrimSpace(r.URL.Query().Get("targetType")); targetType != "" { + where += " AND l.target_type=?" + args = append(args, targetType) + } + if targetID := strings.TrimSpace(r.URL.Query().Get("targetId")); targetID != "" { + where += " AND l.target_id=?" + args = append(args, targetID) + } + if keyword := strings.TrimSpace(r.URL.Query().Get("keyword")); keyword != "" { + where += " AND (a.real_name LIKE ? OR l.request_data LIKE ?)" + like := "%" + keyword + "%" + args = append(args, like, like) + } + if from := strings.TrimSpace(r.URL.Query().Get("from")); from != "" { + where += " AND l.created_at>=?" + args = append(args, from) + } + if to := strings.TrimSpace(r.URL.Query().Get("to")); to != "" { + where += " AND l.created_at<=?" + args = append(args, to) + } var total int64 - _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM admin_audit_logs`).Scan(&total) - rows, err := a.db.QueryContext(r.Context(), `SELECT l.id,l.admin_user_id,a.real_name,l.action,l.target_type,l.target_id,l.request_data,l.ip,l.created_at FROM admin_audit_logs l JOIN admin_users a ON a.id=l.admin_user_id ORDER BY l.created_at DESC LIMIT ? OFFSET ?`, size, offset) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM admin_audit_logs l + JOIN admin_users a ON a.id=l.admin_user_id`+where, args...).Scan(&total) + rows, err := a.db.QueryContext(r.Context(), `SELECT l.id,l.admin_user_id,a.real_name,l.action,l.target_type,l.target_id,l.request_data,l.ip,l.created_at FROM admin_audit_logs l JOIN admin_users a ON a.id=l.admin_user_id`+where+` ORDER BY l.created_at DESC LIMIT ? OFFSET ?`, append(args, size, offset)...) if err != nil { fail(w, 500, 50001, "查询失败") return diff --git a/im/backend/internal/app/admin_ai_logs.go b/im/backend/internal/app/admin_ai_logs.go index 373bfd577..7b3864023 100644 --- a/im/backend/internal/app/admin_ai_logs.go +++ b/im/backend/internal/app/admin_ai_logs.go @@ -40,6 +40,25 @@ func (a *App) adminAILogs(w http.ResponseWriter, r *http.Request) { where = append(where, "l.status=?") args = append(args, status) } + // 排查"这个 AI 为什么这么回",看单条没用,要能把一整个会话拉出来看顺序。 + if conversation := strings.TrimSpace(r.URL.Query().Get("conversationId")); conversation != "" { + value, err := strconv.ParseInt(conversation, 10, 64) + if err != nil || value <= 0 { + fail(w, http.StatusBadRequest, 20001, "会话筛选无效") + return + } + where = append(where, "l.conversation_id=?") + args = append(args, value) + } + if agent := strings.TrimSpace(r.URL.Query().Get("agentUserId")); agent != "" { + value, err := strconv.ParseInt(agent, 10, 64) + if err != nil || value <= 0 { + fail(w, http.StatusBadRequest, 20001, "托管账号筛选无效") + return + } + where = append(where, "l.agent_user_id=?") + args = append(args, value) + } if days := strings.TrimSpace(r.URL.Query().Get("days")); days != "" { value, err := strconv.Atoi(days) if err != nil || value < 1 || value > 90 { diff --git a/im/backend/internal/app/admin_finance.go b/im/backend/internal/app/admin_finance.go index 63629823a..1a0089e61 100644 --- a/im/backend/internal/app/admin_finance.go +++ b/im/backend/internal/app/admin_finance.go @@ -1,6 +1,7 @@ package app import ( + "database/sql" "fmt" "net/http" "strings" @@ -167,6 +168,24 @@ func (a *App) adminRefundOrder(w http.ResponseWriter, r *http.Request) { return } + // 代喂订单的钱有两本账:orders 和 pet_feed_tasks。任务页退款会同时记两边, + // 订单页原来只记 orders——于是先从订单页退一次,任务页还以为一分没退, + // 同一笔钱能再退一遍。这里补上另一边,并且沿用任务页那道门槛。 + var feedTaskID int64 + if productType == productPetFeed { + var taskStatus string + if a.db.QueryRowContext(r.Context(), `SELECT id,status FROM pet_feed_tasks WHERE id=?`, + productID).Scan(&feedTaskID, &taskStatus) != nil { + fail(w, http.StatusNotFound, 30001, "对应的代喂任务不存在") + return + } + if feedStatusSettledOrArbitrated(taskStatus) { + fail(w, http.StatusBadRequest, 20001, + "这笔代喂已经结算,报酬在喂养者账上。要退款请到「上门代喂」页面操作,那里才能确认由平台承担") + return + } + } + live := a.configPlain(r.Context(), "payment.mode", "sandbox") == "live" channel := "gateway" if req.Offline || providerOrderNo == "" || strings.HasPrefix(providerOrderNo, "sandbox:") || !live { @@ -197,10 +216,32 @@ func (a *App) adminRefundOrder(w http.ResponseWriter, r *http.Request) { if full { newStatus = "REFUNDED" } - if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET refunded_cent=refunded_cent+?,status=? WHERE id=?`, - refundAmount, newStatus, id); err != nil { - fail(w, http.StatusInternalServerError, 50001, "退款记账失败") - return + // 代喂订单的这一笔由 recordFeedRefund 统一处理,这里只管其它商品, + // 否则同一笔金额会被加两次。 + if feedTaskID == 0 { + if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET refunded_cent=refunded_cent+?,status=? WHERE id=?`, + refundAmount, newStatus, id); err != nil { + fail(w, http.StatusInternalServerError, 50001, "退款记账失败") + return + } + } else if newStatus != status { + if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status=? WHERE id=?`, newStatus, id); err != nil { + fail(w, http.StatusInternalServerError, 50001, "退款记账失败") + return + } + } + if feedTaskID > 0 { + // 和代喂任务页共用同一个记账函数:任务已退金额、退款流水、任务状态 + // 全部在那里一起处理,这边不再各写一份。 + if err = recordFeedRefund(r.Context(), tx, feedRefundEntry{ + TaskID: feedTaskID, OrderID: sql.NullInt64{Int64: id, Valid: true}, + RefundNo: refundNo, UserID: userID, AmountCen: refundAmount, + Channel: channel, PlatformBears: false, + Reason: req.Reason, OperatorID: current(r).ID, + }); err != nil { + fail(w, http.StatusInternalServerError, 50001, "退款记账失败") + return + } } // 退完才收回买到的东西:半份会员仍然是会员,代喂任务同理。 if full { diff --git a/im/backend/internal/app/ai_providers.go b/im/backend/internal/app/ai_providers.go index e2fb1b97c..14d54c8f0 100644 --- a/im/backend/internal/app/ai_providers.go +++ b/im/backend/internal/app/ai_providers.go @@ -71,9 +71,9 @@ type aiModel struct { // Provider-specific request fields, merged into the call body. This is how a // model that thinks by default is asked not to, without a code change: // {"thinking":{"type":"disabled"}}. - Extra map[string]any `json:"extra,omitempty"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + Extra map[string]any `json:"extra,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } func aiProtocolName(protocol string) string { diff --git a/im/backend/internal/app/app.go b/im/backend/internal/app/app.go index d0ac035b6..d45fdb954 100644 --- a/im/backend/internal/app/app.go +++ b/im/backend/internal/app/app.go @@ -259,7 +259,19 @@ func (a *App) userRoutes() []rest.Route { {Method: http.MethodPost, Path: "/api/v1/pet/followups/:id", Handler: auth(a.submitAdoptionFollowup)}, {Method: http.MethodGet, Path: "/api/v1/pet/mating", Handler: auth(a.matingListings)}, {Method: http.MethodPost, Path: "/api/v1/pet/mating", Handler: auth(a.createMatingListing)}, + {Method: http.MethodGet, Path: "/api/v1/pet/mating/:id", Handler: auth(a.matingDetail)}, {Method: http.MethodPost, Path: "/api/v1/pet/mating/:id/close", Handler: auth(a.closeMatingListing)}, + {Method: http.MethodPost, Path: "/api/v1/pet/mating/:id/interest", Handler: auth(a.createMatingInterest)}, + {Method: http.MethodPost, Path: "/api/v1/pet/mating-interests/:id/decide", Handler: auth(a.decideMatingInterest)}, + {Method: http.MethodGet, Path: "/api/v1/pet/mating-interests", Handler: auth(a.myMatingInterests)}, + {Method: http.MethodGet, Path: "/api/v1/pet/breeds", Handler: auth(a.petBreeds)}, + {Method: http.MethodPost, Path: "/api/v1/pet/listing-conversation", Handler: auth(a.petListingConversation)}, + {Method: http.MethodPost, Path: "/api/v1/pet/person-conversation", Handler: auth(a.petPersonConversation)}, + {Method: http.MethodGet, Path: "/api/v1/pet/contacts", Handler: auth(a.petContacts)}, + {Method: http.MethodPost, Path: "/api/v1/pet/contact-requests", Handler: auth(a.requestPetContact)}, + {Method: http.MethodGet, Path: "/api/v1/pet/contact-requests", Handler: auth(a.petContactRequests)}, + {Method: http.MethodPost, Path: "/api/v1/pet/contact-requests/:id/decide", Handler: auth(a.decidePetContact)}, + {Method: http.MethodGet, Path: "/api/v1/pet/contact-requests/:id/detail", Handler: auth(a.petContactDetail)}, {Method: http.MethodGet, Path: "/api/v1/pet/addresses", Handler: auth(a.petAddresses)}, {Method: http.MethodPost, Path: "/api/v1/pet/addresses", Handler: auth(a.createPetAddress)}, {Method: http.MethodGet, Path: "/api/v1/pet/sitter", Handler: auth(a.sitterProfile)}, @@ -350,11 +362,18 @@ func (a *App) adminRoutes() []rest.Route { {Method: http.MethodGet, Path: "/admin/v1/posts/:id", Handler: permit("content:view", a.adminPostDetail)}, {Method: http.MethodDelete, Path: "/admin/v1/posts/:id", Handler: permit("content:manage", a.adminDeletePost)}, {Method: http.MethodGet, Path: "/admin/v1/reports", Handler: permit("reports:handle", a.adminReports)}, + {Method: http.MethodGet, Path: "/admin/v1/reports/:id/target", Handler: permit("reports:handle", a.adminReportTarget)}, + {Method: http.MethodGet, Path: "/admin/v1/users/:id/overview", Handler: permit("users:view", a.adminUserOverview)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/notify", Handler: permit("users:manage", a.adminNotifyUser)}, + {Method: http.MethodPost, Path: "/admin/v1/users/batch", Handler: permit("users:manage", a.adminBatchUsers)}, + {Method: http.MethodPost, Path: "/admin/v1/wallet/withdrawals/batch", Handler: permit("finance:manage", a.adminBatchWithdrawals)}, + {Method: http.MethodGet, Path: "/admin/v1/finance/operations", Handler: permit("orders:view", a.adminMoneyOperations)}, + {Method: http.MethodGet, Path: "/admin/v1/finance/operations/export", Handler: permit("orders:view", a.adminExportMoneyOperations)}, {Method: http.MethodPost, Path: "/admin/v1/reports/:id/handle", Handler: permit("reports:handle", a.adminHandleReport)}, {Method: http.MethodGet, Path: "/admin/v1/risk/users", Handler: permit("risk:view", a.adminRiskUsers)}, {Method: http.MethodGet, Path: "/admin/v1/risk/events", Handler: permit("risk:view", a.adminRiskEvents)}, - {Method: http.MethodPost, Path: "/admin/v1/risk/users/:id/whitelist", Handler: permit("risk:view", a.adminWhitelistRiskUser)}, - {Method: http.MethodPost, Path: "/admin/v1/risk/events/:id/handle", Handler: permit("risk:view", a.adminHandleRiskEvent)}, + {Method: http.MethodPost, Path: "/admin/v1/risk/users/:id/whitelist", Handler: permit("risk:manage", a.adminWhitelistRiskUser)}, + {Method: http.MethodPost, Path: "/admin/v1/risk/events/:id/handle", Handler: permit("risk:manage", a.adminHandleRiskEvent)}, {Method: http.MethodPost, Path: "/admin/v1/posts/batch", Handler: permit("content:manage", a.adminBatchPosts)}, {Method: http.MethodPost, Path: "/admin/v1/messages/batch", Handler: permit("messages:manage", a.adminBatchMessages)}, {Method: http.MethodPost, Path: "/admin/v1/reports/batch", Handler: permit("reports:handle", a.adminBatchReports)}, @@ -374,7 +393,7 @@ func (a *App) adminRoutes() []rest.Route { {Method: http.MethodDelete, Path: "/admin/v1/orders/:id", Handler: permit("orders:manage", a.adminDeleteOrder)}, {Method: http.MethodPost, Path: "/admin/v1/orders/:id/pay", Handler: permit("orders:manage", a.adminMarkOrderPaid)}, {Method: http.MethodPost, Path: "/admin/v1/orders/:id/close", Handler: permit("orders:manage", a.adminCloseOrder)}, - {Method: http.MethodPost, Path: "/admin/v1/orders/:id/refund", Handler: permit("orders:manage", a.adminRefundOrder)}, + {Method: http.MethodPost, Path: "/admin/v1/orders/:id/refund", Handler: permit("finance:manage", a.adminRefundOrder)}, {Method: http.MethodGet, Path: "/admin/v1/orders/:id/refunds", Handler: permit("orders:view", a.adminOrderRefunds)}, {Method: http.MethodGet, Path: "/admin/v1/finance/overview", Handler: permit("orders:view", a.adminFinanceOverview)}, {Method: http.MethodGet, Path: "/admin/v1/notifications/broadcasts", Handler: permit("users:view", a.adminBroadcasts)}, @@ -394,17 +413,37 @@ func (a *App) adminRoutes() []rest.Route { {Method: http.MethodPut, Path: "/admin/v1/pet/listings/:id", Handler: permit("users:manage", a.adminUpdatePetListing)}, {Method: http.MethodDelete, Path: "/admin/v1/pet/listings/:id", Handler: permit("users:manage", a.adminDeletePetListing)}, {Method: http.MethodPut, Path: "/admin/v1/pet/feed-tasks/:id", Handler: permit("users:manage", a.adminUpdateFeedTask)}, - {Method: http.MethodPost, Path: "/admin/v1/pet/feed-tasks/:id/refund", Handler: permit("users:manage", a.adminRefundFeedTask)}, + {Method: http.MethodPost, Path: "/admin/v1/pet/feed-tasks/:id/refund", Handler: permit("finance:manage", a.adminRefundFeedTask)}, {Method: http.MethodGet, Path: "/admin/v1/pet/feed-tasks/:id/refunds", Handler: permit("users:view", a.adminFeedRefunds)}, {Method: http.MethodGet, Path: "/admin/v1/pet/overview", Handler: permit("users:view", a.adminPetOverview)}, {Method: http.MethodGet, Path: "/admin/v1/pet/sitters", Handler: permit("users:view", a.adminSitters)}, {Method: http.MethodPost, Path: "/admin/v1/pet/sitters/:id/review", Handler: permit("users:manage", a.adminReviewSitter)}, + // 保证金是押在平台手里的用户的钱,动它归财务权限,不跟资质审核混在一起。 + {Method: http.MethodGet, Path: "/admin/v1/pet/deposits", Handler: permit("finance:manage", a.adminDeposits)}, + {Method: http.MethodGet, Path: "/admin/v1/pet/deposits/:id/entries", Handler: permit("finance:manage", a.adminDepositHistory)}, + {Method: http.MethodPost, Path: "/admin/v1/pet/deposits/:id/adjust", Handler: permit("finance:manage", a.adminAdjustDeposit)}, + // 一直在写、却没有任何读取路径的那几张表,补上入口。 + {Method: http.MethodGet, Path: "/admin/v1/payment-events", Handler: permit("finance:view", a.adminPaymentEvents)}, + {Method: http.MethodGet, Path: "/admin/v1/payment-events/:id/payload", Handler: permit("finance:view", a.adminPaymentEventPayload)}, + {Method: http.MethodGet, Path: "/admin/v1/pet/feed-reviews", Handler: permit("content:view", a.adminFeedReviews)}, + {Method: http.MethodPost, Path: "/admin/v1/pet/feed-reviews/:id/visibility", Handler: permit("content:manage", a.adminHideFeedReview)}, + {Method: http.MethodGet, Path: "/admin/v1/pet/requests", Handler: permit("users:view", a.adminPetRequests)}, + {Method: http.MethodGet, Path: "/admin/v1/users/:id/consents", Handler: permit("users:view", a.adminUserConsents)}, {Method: http.MethodGet, Path: "/admin/v1/pet/feed-tasks", Handler: permit("users:view", a.adminFeedTasks)}, - {Method: http.MethodPost, Path: "/admin/v1/pet/feed-tasks/:id/escrow", Handler: permit("users:manage", a.adminEscrowFeedTask)}, + {Method: http.MethodGet, Path: "/admin/v1/pet/feed-tasks/:id", Handler: permit("users:view", a.adminFeedTaskDetail)}, + {Method: http.MethodPost, Path: "/admin/v1/pet/feed-tasks/:id/assign", Handler: permit("users:manage", a.adminAssignFeedTask)}, + {Method: http.MethodPost, Path: "/admin/v1/pet/feed-tasks/:id/settle", Handler: permit("finance:manage", a.adminSettleFeedTask)}, + {Method: http.MethodGet, Path: "/admin/v1/pet/sitter-candidates", Handler: permit("users:view", a.adminAvailableSitters)}, + {Method: http.MethodPost, Path: "/admin/v1/media/upload", Handler: permit("users:manage", a.adminUploadMedia)}, + {Method: http.MethodGet, Path: "/admin/v1/pet/breeds", Handler: permit("users:view", a.adminBreeds)}, + {Method: http.MethodPost, Path: "/admin/v1/pet/breeds", Handler: permit("users:manage", a.adminSaveBreed)}, + {Method: http.MethodDelete, Path: "/admin/v1/pet/breeds/:id", Handler: permit("users:manage", a.adminDeleteBreed)}, + {Method: http.MethodGet, Path: "/admin/v1/pets/:id/relations", Handler: permit("users:view", a.adminPetRelations)}, + {Method: http.MethodPost, Path: "/admin/v1/pet/feed-tasks/:id/escrow", Handler: permit("finance:manage", a.adminEscrowFeedTask)}, {Method: http.MethodGet, Path: "/admin/v1/pet/disputes", Handler: permit("users:view", a.adminFeedDisputes)}, - {Method: http.MethodPost, Path: "/admin/v1/pet/disputes/:id/handle", Handler: permit("users:manage", a.adminHandleFeedDispute)}, + {Method: http.MethodPost, Path: "/admin/v1/pet/disputes/:id/handle", Handler: permit("finance:manage", a.adminHandleFeedDispute)}, {Method: http.MethodGet, Path: "/admin/v1/wallet/withdrawals", Handler: permit("users:view", a.adminWithdrawals)}, - {Method: http.MethodPost, Path: "/admin/v1/wallet/withdrawals/:id/handle", Handler: permit("users:manage", a.adminHandleWithdrawal)}, + {Method: http.MethodPost, Path: "/admin/v1/wallet/withdrawals/:id/handle", Handler: permit("finance:manage", a.adminHandleWithdrawal)}, {Method: http.MethodGet, Path: "/admin/v1/wallet/audit", Handler: permit("users:view", a.adminWalletAudit)}, {Method: http.MethodGet, Path: "/admin/v1/chat-media", Handler: permit("messages:view", a.adminChatMedia)}, {Method: http.MethodPut, Path: "/admin/v1/chat-media/retention", Handler: permit("messages:manage", a.adminUpdateChatMediaRetention)}, @@ -476,21 +515,31 @@ func decode(r *http.Request, out any) error { return nil } -func pagination(r *http.Request) (int, int, int) { +// 项目里曾经有两套分页约定:一套认 size、上限 100,另一套认 pageSize、 +// 上限 50。名字传错不会报错,只会静默退回每页 20 条——查不出来的那种问题。 +// 现在两个参数名都认,取到哪个算哪个,上限由调用方给。 +func readPage(r *http.Request, defaultSize, maxSize int) (int, int, int) { page, _ := strconv.Atoi(r.URL.Query().Get("page")) size, _ := strconv.Atoi(r.URL.Query().Get("size")) + if size < 1 { + size, _ = strconv.Atoi(r.URL.Query().Get("pageSize")) + } if page < 1 { page = 1 } if size < 1 { - size = 20 + size = defaultSize } - if size > 100 { - size = 100 + if size > maxSize { + size = maxSize } return page, size, (page - 1) * size } +func pagination(r *http.Request) (int, int, int) { + return readPage(r, 20, 100) +} + func pathID(r *http.Request) (int64, error) { parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") for i := len(parts) - 1; i >= 0; i-- { diff --git a/im/backend/internal/app/client_productization.go b/im/backend/internal/app/client_productization.go index b8df5f03c..8312cd6b2 100644 --- a/im/backend/internal/app/client_productization.go +++ b/im/backend/internal/app/client_productization.go @@ -83,19 +83,10 @@ func (a *App) reserveDailyLike(tx *sql.Tx, r *http.Request, targetType string, t return err } +// 客户端接口的每页上限比管理端小一些,其余行为和 pagination 完全一致: +// size 和 pageSize 两个名字都认。 func pageOptions(r *http.Request) (int, int, int) { - page, _ := strconv.Atoi(r.URL.Query().Get("page")) - pageSize, _ := strconv.Atoi(r.URL.Query().Get("pageSize")) - if page < 1 { - page = 1 - } - if pageSize < 1 { - pageSize = 20 - } - if pageSize > 50 { - pageSize = 50 - } - return page, pageSize, (page - 1) * pageSize + return readPage(r, 20, 50) } func nullableInt64(value sql.NullInt64) any { diff --git a/im/backend/internal/app/content_keywords.go b/im/backend/internal/app/content_keywords.go index 91eb97c8a..f5975ca08 100644 --- a/im/backend/internal/app/content_keywords.go +++ b/im/backend/internal/app/content_keywords.go @@ -164,7 +164,8 @@ type keywordRequest struct { Status int `json:"status"` } -var keywordScopes = map[string]bool{"all": true, "message": true, "post": true, "adoption": true, "nickname": true} +var keywordScopes = map[string]bool{"all": true, "message": true, "post": true, "adoption": true, + "mating": true, "pet": true, "nickname": true} func validKeyword(req *keywordRequest) string { req.Word = strings.TrimSpace(req.Word) diff --git a/im/backend/internal/app/integration.go b/im/backend/internal/app/integration.go index 233bb8927..e236cf4fe 100644 --- a/im/backend/internal/app/integration.go +++ b/im/backend/internal/app/integration.go @@ -26,6 +26,12 @@ type integrationFieldSpec struct { Required bool Options []string Providers []string + // Scenes 为空表示所有支付场景共用;否则只在这些场景下显示。 + Scenes []string + // 支付配置分两级:Section 是外层(哪一端),Channel 是内层(哪个渠道)。 + // Channel 为空表示这一端里和渠道无关的设置。 + Section string + Channel string Description string } @@ -39,6 +45,9 @@ type integrationFieldView struct { Required bool `json:"required"` Options []string `json:"options,omitempty"` Providers []string `json:"providers,omitempty"` + Scenes []string `json:"scenes,omitempty"` + Section string `json:"section,omitempty"` + Channel string `json:"channel,omitempty"` Description string `json:"description"` } @@ -200,26 +209,32 @@ var integrationSpecs = map[string][]integrationFieldSpec{ {Key: "push.show_preview", Label: "通知显示消息内容", Input: "boolean", Required: true, Description: "关闭后只提示「给你发来一条消息」,不在锁屏上暴露正文"}, }, "payment": { - {Key: "payment.mode", Label: "支付模式", Input: "select", Required: true, Options: []string{"sandbox", "live"}, Description: "sandbox 可直接完成本地支付闭环"}, - {Key: "payment.gateway.create_url", Label: "支付网关下单地址", Input: "text", Description: "live 模式必填,生产环境必须使用 HTTPS"}, - {Key: "payment.gateway.refund_url", Label: "支付网关退款地址", Input: "text", Description: "live 模式退款必填,生产环境必须使用 HTTPS"}, - {Key: "payment.gateway.token", Label: "支付网关访问令牌", Input: "secret", Description: "live 模式必填,以 Bearer Token 调用统一支付网关"}, - {Key: "payment.gateway.notify_secret", Label: "支付回调签名密钥", Input: "secret", Description: "live 模式必填,至少 32 位随机字符串,用于 HMAC-SHA256 验签"}, - {Key: "payment.gateway.notify_url", Label: "支付异步回调地址", Input: "text", Description: "live 模式必填,例如 https://api.example.com/api/v1/payment/notify"}, - {Key: "payment.gateway.return_url", Label: "支付完成返回地址", Input: "text", Description: "H5 支付完成后返回的客户端地址"}, - {Key: "payment.gateway.timeout_seconds", Label: "网关超时(秒)", Input: "number", Description: "建议 5 至 30 秒"}, - {Key: "payment.alipay.enabled", Label: "启用支付宝", Input: "boolean", Required: true, Description: "控制客户端支付宝入口"}, - {Key: "payment.alipay.app_id", Label: "支付宝 APPID", Input: "text", Description: "开放平台应用 APPID"}, - {Key: "payment.alipay.private_key", Label: "支付宝应用私钥", Input: "secret", Description: "敏感字段加密存储"}, - {Key: "payment.alipay.public_key", Label: "支付宝公钥", Input: "secret", Description: "用于验签"}, - {Key: "payment.alipay.notify_url", Label: "支付宝通知地址", Input: "text", Description: "必须是公网 HTTPS 地址"}, - {Key: "payment.wechat.enabled", Label: "启用微信支付", Input: "boolean", Required: true, Description: "控制客户端微信支付入口"}, - {Key: "payment.wechat.app_id", Label: "微信 AppID", Input: "text", Description: "移动应用或小程序 AppID"}, - {Key: "payment.wechat.mch_id", Label: "微信商户号", Input: "text", Description: "微信支付商户号"}, - {Key: "payment.wechat.api_v3_key", Label: "APIv3 密钥", Input: "secret", Description: "敏感字段加密存储"}, - {Key: "payment.wechat.private_key", Label: "商户私钥", Input: "secret", Description: "PEM 内容,敏感字段加密存储"}, - {Key: "payment.wechat.serial_no", Label: "证书序列号", Input: "text", Description: "商户 API 证书序列号"}, - {Key: "payment.wechat.notify_url", Label: "微信通知地址", Input: "text", Description: "必须是公网 HTTPS 地址"}, + // 一、通用网关:和哪一端、哪个渠道都无关,钱都从这里走。 + {Key: "payment.mode", Label: "支付模式", Input: "select", Required: true, Options: []string{"sandbox", "live"}, Section: "common", Description: "sandbox 可直接完成本地支付闭环,不经过真实渠道"}, + {Key: "payment.gateway.create_url", Label: "支付网关下单地址", Input: "text", Section: "common", Description: "【由服务商提供】对接的聚合支付网关给的下单接口,本系统无法自动推断;必须 HTTPS"}, + {Key: "payment.gateway.refund_url", Label: "支付网关退款地址", Input: "text", Section: "common", Description: "【由服务商提供】同一网关的退款接口;不填则后台退款只能走线下"}, + {Key: "payment.gateway.token", Label: "支付网关访问令牌", Input: "secret", Section: "common", Description: "【由服务商提供】调用上面两个接口时用的 Bearer Token"}, + {Key: "payment.gateway.notify_secret", Label: "支付回调签名密钥", Input: "secret", Section: "common", Description: "【本系统自己定】点右边「生成」得到一串随机值,把同一串配到网关那边;用于 HMAC-SHA256 验签,至少 32 位"}, + {Key: "payment.gateway.notify_url", Label: "支付异步回调地址", Input: "text", Section: "common", Description: "【本系统的接口,已自动填好】把这个地址告诉网关,让它把支付结果回调到这里"}, + {Key: "payment.gateway.timeout_seconds", Label: "网关超时(秒)", Input: "number", Section: "common", Description: "建议 5 至 30 秒"}, + {Key: "payment.alipay.enabled", Label: "启用支付宝", Input: "boolean", Required: true, Section: "common", Channel: "alipay", Description: "总开关:关掉后三个端都不显示支付宝"}, + {Key: "payment.wechat.enabled", Label: "启用微信支付", Input: "boolean", Required: true, Section: "common", Channel: "wechat", Description: "总开关:关掉后三个端都不显示微信支付"}, + + // 二、网页支付。 + {Key: "payment.scene.h5.enabled", Label: "启用网页支付", Input: "boolean", Required: true, Scenes: []string{"h5"}, Section: "h5", Description: "关闭后网页端拿不到任何支付方式"}, + {Key: "payment.gateway.return_url", Label: "支付完成返回地址", Input: "text", Scenes: []string{"h5"}, Section: "h5", Description: "【本站 H5 页面,已自动填好】付完从收银台跳回这里;只有网页支付用得上"}, + {Key: "payment.alipay.h5_app_id", Label: "支付宝 APPID(网页)", Input: "text", Scenes: []string{"h5"}, Section: "h5", Channel: "alipay", Description: "【支付宝开放平台后台复制】留空则沿用下面 App 段里那个"}, + {Key: "payment.wechat.h5_app_id", Label: "微信公众号 AppID", Input: "text", Scenes: []string{"h5"}, Section: "h5", Channel: "wechat", Description: "【微信公众平台后台复制】网页内微信支付(JSAPI)用;与 App、小程序的都不是同一个"}, + + // 三、App 支付。 + {Key: "payment.scene.app.enabled", Label: "启用 App 支付", Input: "boolean", Required: true, Scenes: []string{"app"}, Section: "app", Description: "关闭后 App 端拿不到任何支付方式"}, + {Key: "payment.alipay.app_app_id", Label: "支付宝 APPID(App)", Input: "text", Scenes: []string{"app"}, Section: "app", Channel: "alipay", Description: "【支付宝开放平台后台复制】多数商户和网页用同一个,填一样的即可"}, + {Key: "payment.wechat.app_app_id", Label: "微信移动应用 AppID", Input: "text", Scenes: []string{"app"}, Section: "app", Channel: "wechat", Description: "【微信开放平台后台复制】「移动应用」的 AppID,须与 App 打包配置一致"}, + + // 四、小程序支付。 + {Key: "payment.scene.mp.enabled", Label: "启用小程序支付", Input: "boolean", Required: true, Scenes: []string{"mp"}, Section: "mp", Description: "关闭后小程序端拿不到任何支付方式"}, + {Key: "payment.alipay.mp_app_id", Label: "支付宝小程序 APPID", Input: "text", Scenes: []string{"mp"}, Section: "mp", Channel: "alipay", Description: "【支付宝开放平台后台复制】支付宝小程序自己的 APPID"}, + {Key: "payment.wechat.mp_app_id", Label: "微信小程序 AppID", Input: "text", Scenes: []string{"mp"}, Section: "mp", Channel: "wechat", Description: "【微信公众平台-小程序后台复制】小程序自己的 AppID"}, }, } @@ -354,10 +369,21 @@ func (a *App) adminIntegration(w http.ResponseWriter, r *http.Request) { if group == "oauth" { applies = false // OAuth 必填项由各渠道的启用状态独立校验。 } + if group == "payment" && len(spec.Scenes) > 0 { + // 只有真正开着的场景才参与"配置是否完整"的判断。 + // 小程序没上线却因为缺小程序 AppID 报未配置,是误报。 + applies = false + for _, scene := range spec.Scenes { + if a.paymentSceneEnabled(r.Context(), scene) { + applies = true + break + } + } + } if applies && spec.Required && !hasValue { configured = false } - fields = append(fields, integrationFieldView{Key: spec.Key, Label: spec.Label, Value: displayValue, Input: spec.Input, Secret: secret, HasValue: hasValue, Required: spec.Required, Options: spec.Options, Providers: spec.Providers, Description: spec.Description}) + fields = append(fields, integrationFieldView{Key: spec.Key, Label: spec.Label, Value: displayValue, Input: spec.Input, Secret: secret, HasValue: hasValue, Required: spec.Required, Options: spec.Options, Providers: spec.Providers, Scenes: spec.Scenes, Section: spec.Section, Channel: spec.Channel, Description: spec.Description}) } reply(w, map[string]any{"group": group, "configured": configured, "fields": fields, "secretMask": maskedSecret}) } @@ -515,7 +541,34 @@ func (a *App) adminTestIntegration(w http.ResponseWriter, r *http.Request) { } } } - reply(w, map[string]any{"success": true, "message": "支付配置校验通过", "mode": mode, "channels": channels}) + // 逐个场景报状态。"网关通了"不等于"三端都能付"—— + // 微信每一端要各自的 AppID,缺哪个就说哪个,别让人自己猜。 + sceneReports := []map[string]any{} + openScenes := 0 + for _, scene := range paymentScenes { + enabled := a.paymentSceneEnabled(r.Context(), scene) + missing := []string{} + if enabled { + openScenes++ + for _, channel := range channels { + code, _ := channel["code"].(string) + if !a.paymentSceneReady(r.Context(), code, scene) { + missing = append(missing, fmt.Sprintf("%v", channel["name"])) + } + } + } + sceneReports = append(sceneReports, map[string]any{ + "scene": scene, "label": paymentSceneLabels[scene], + "enabled": enabled, "ready": enabled && len(missing) == 0, + "missingChannels": missing, + }) + } + if openScenes == 0 { + fail(w, 400, 20001, "三个支付场景全部关闭,客户端拿不到任何支付方式") + return + } + reply(w, map[string]any{"success": true, "message": "支付配置校验通过", "mode": mode, + "channels": channels, "scenes": sceneReports}) case "ai": if err := a.validateAIConfig(r.Context()); err != nil { fail(w, http.StatusBadRequest, 20001, err.Error()) diff --git a/im/backend/internal/app/media.go b/im/backend/internal/app/media.go index f1f282ef6..9024163e1 100644 --- a/im/backend/internal/app/media.go +++ b/im/backend/internal/app/media.go @@ -8,6 +8,7 @@ import ( "io" "log" "net/http" + "os" "path/filepath" "regexp" "strings" @@ -56,7 +57,36 @@ func mediaThumbnailURL(source string) string { return source } +// localStorageDirectory resolves where local media actually lives. +// +// migrations/018 seeds this key as the relative path "./uploads", which only +// works when the process happens to be started from a directory that has one. +// On a release-directory layout it does not, and then every /uploads/... came +// back 404 while the files sat safely on disk. A configured directory that +// does not exist is worse than no configuration at all, so fall back to the +// absolute path the environment gave us. +func (a *App) localStorageDirectory(ctx context.Context) string { + configured := strings.TrimSpace(a.configPlain(ctx, "storage.local.directory", "")) + if configured != "" { + if info, err := os.Stat(configured); err == nil && info.IsDir() { + return configured + } + log.Printf("storage.local.directory %q is not a usable directory; falling back to %q", + configured, a.config.MediaDir) + } + return a.config.MediaDir +} + func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) { + a.uploadMediaAs(w, r, current(r).ID) +} + +// uploadMediaAs is the same pipeline for both callers. It is split out because +// the console had no way to upload anything at all: every image field there was +// a text box asking an operator to paste a URL they had no way to obtain. +// Console uploads are booked against owner 0 — they belong to the platform, not +// to whichever member of staff happened to click the button. +func (a *App) uploadMediaAs(w http.ResponseWriter, r *http.Request, ownerID int64) { // Multipart boundaries and headers add a small amount of overhead. Keep the // actual file limit at 16 MiB without rejecting a file that is exactly at // that limit solely because of the multipart envelope. @@ -154,7 +184,7 @@ func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) { } provider := a.configPlain(r.Context(), "storage.provider", "local") - name := fmt.Sprintf("%d-%d%s", current(r).ID, time.Now().UnixNano(), extension) + name := fmt.Sprintf("%d-%d%s", ownerID, time.Now().UnixNano(), extension) objectKey := name if provider != "local" { prefix := strings.Trim(a.configPlain(r.Context(), "storage.object_prefix", "media"), "/") @@ -169,7 +199,7 @@ func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) { var storage mediaObjectStorage closeStorage := func() {} if provider == "local" { - storage = localMediaStorage{directory: a.configPlain(r.Context(), "storage.local.directory", a.config.MediaDir)} + storage = localMediaStorage{directory: a.localStorageDirectory(r.Context())} baseURL := strings.TrimSpace(a.configPlain(r.Context(), "storage.local.public_base_url", "")) if baseURL == "" { scheme := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]) @@ -221,7 +251,7 @@ func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) { } } - result, err := a.db.ExecContext(r.Context(), `INSERT INTO media_assets(owner_user_id,media_type,storage_provider,bucket,object_key,public_url,mime_type,file_size,moderation_status,status) VALUES(?,?,?,?,?,'',?,?,1,0)`, current(r).ID, mediaType, provider, bucket, objectKey, contentType, size) + result, err := a.db.ExecContext(r.Context(), `INSERT INTO media_assets(owner_user_id,media_type,storage_provider,bucket,object_key,public_url,mime_type,file_size,moderation_status,status) VALUES(?,?,?,?,?,'',?,?,1,0)`, ownerID, mediaType, provider, bucket, objectKey, contentType, size) if err != nil { fail(w, http.StatusInternalServerError, 50001, "创建媒体记录失败") return @@ -266,6 +296,6 @@ func (a *App) serveMedia(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("X-Content-Type-Options", "nosniff") - directory := a.configPlain(r.Context(), "storage.local.directory", a.config.MediaDir) + directory := a.localStorageDirectory(r.Context()) http.ServeFile(w, r, filepath.Join(directory, filepath.Base(name))) } diff --git a/im/backend/internal/app/membership.go b/im/backend/internal/app/membership.go index 638c35460..dbfd8e165 100644 --- a/im/backend/internal/app/membership.go +++ b/im/backend/internal/app/membership.go @@ -26,8 +26,38 @@ func (a *App) availablePaymentChannels(ctx context.Context) []map[string]any { return channels } +// availablePaymentChannelsForScene 在渠道总开关之上再按场景过滤: +// 场景没开、或者这个场景缺自己那个 AppID,就不要把入口摆出去—— +// 让用户点进去再失败,比一开始就不显示更糟。 +func (a *App) availablePaymentChannelsForScene(ctx context.Context, scene string) []map[string]any { + channels := a.availablePaymentChannels(ctx) + if scene == "" { + return channels + } + filtered := []map[string]any{} + for _, channel := range channels { + code, _ := channel["code"].(string) + ready := a.paymentSceneReady(ctx, code, scene) + if !ready && !a.paymentSceneEnabled(ctx, scene) { + continue + } + if configured, ok := channel["configured"].(bool); ok { + channel["configured"] = configured && ready + } + channel["scene"] = scene + filtered = append(filtered, channel) + } + return filtered +} + func (a *App) paymentChannels(w http.ResponseWriter, r *http.Request) { - reply(w, map[string]any{"mode": a.configPlain(r.Context(), "payment.mode", "sandbox"), "items": a.availablePaymentChannels(r.Context())}) + scene := requestPaymentScene(r) + reply(w, map[string]any{ + "mode": a.configPlain(r.Context(), "payment.mode", "sandbox"), + "scene": scene, + "sceneEnabled": scene == "" || a.paymentSceneEnabled(r.Context(), scene), + "items": a.availablePaymentChannelsForScene(r.Context(), scene), + }) } type planView struct { @@ -141,15 +171,25 @@ func (a *App) payOrder(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusServiceUnavailable, 50003, "支付模式配置无效") return } + scene := requestPaymentScene(r) + if scene != "" && !a.paymentSceneEnabled(r.Context(), scene) { + fail(w, http.StatusServiceUnavailable, 50003, paymentSceneLabels[scene]+"当前未开放") + return + } if mode == "live" { - var orderNo, status, channel, subject, providerOrderNo, checkoutURL string + var orderNo, status, channel, subject, providerOrderNo, checkoutURL, orderScene string var amountCent int var paymentPayload sql.NullString - err = a.db.QueryRowContext(r.Context(), `SELECT o.order_no,o.amount_cent,o.status,o.channel,COALESCE(p.name,'会员套餐'),o.provider_order_no,o.checkout_url,o.payment_payload FROM orders o LEFT JOIN membership_plans p ON p.id=o.product_id WHERE o.id=? AND o.user_id=? AND o.deleted_at IS NULL`, id, current(r).ID).Scan(&orderNo, &amountCent, &status, &channel, &subject, &providerOrderNo, &checkoutURL, &paymentPayload) + err = a.db.QueryRowContext(r.Context(), `SELECT o.order_no,o.amount_cent,o.status,o.channel,COALESCE(p.name,'会员套餐'),o.provider_order_no,o.checkout_url,o.payment_payload,o.payment_scene FROM orders o LEFT JOIN membership_plans p ON p.id=o.product_id WHERE o.id=? AND o.user_id=? AND o.deleted_at IS NULL`, id, current(r).ID).Scan(&orderNo, &amountCent, &status, &channel, &subject, &providerOrderNo, &checkoutURL, &paymentPayload, &orderScene) if err != nil { fail(w, http.StatusNotFound, 30001, "订单不存在") return } + if scene != "" && !a.paymentSceneReady(r.Context(), channel, scene) { + fail(w, http.StatusServiceUnavailable, 50003, + paymentSceneLabels[scene]+"尚未配置完成,请换一种支付方式") + return + } if status == "PAID" { reply(w, map[string]any{"success": true, "status": "PAID", "mode": mode}) return @@ -158,7 +198,9 @@ func (a *App) payOrder(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusBadRequest, 20001, "当前订单状态无法支付") return } - if providerOrderNo != "" && (checkoutURL != "" || paymentPayload.Valid) { + // 同一笔订单在网页上下过单、再回 App 里付,缓存的是给浏览器用的跳转地址, + // 在 App 里唤不起来。场景不一致就重新问网关要一次。 + if providerOrderNo != "" && orderScene == scene && (checkoutURL != "" || paymentPayload.Valid) { var appPayload map[string]any if paymentPayload.Valid && paymentPayload.String != "" { _ = json.Unmarshal([]byte(paymentPayload.String), &appPayload) @@ -166,7 +208,7 @@ func (a *App) payOrder(w http.ResponseWriter, r *http.Request) { reply(w, map[string]any{"success": true, "mode": mode, "status": status, "providerOrderNo": providerOrderNo, "checkoutUrl": checkoutURL, "appPayload": appPayload}) return } - gatewayResult, gatewayErr := a.createGatewayPayment(r.Context(), paymentGatewayOrder{OrderNo: orderNo, AmountCent: amountCent, Channel: channel, Subject: subject, UserID: current(r).ID}) + gatewayResult, gatewayErr := a.createGatewayPayment(r.Context(), paymentGatewayOrder{OrderNo: orderNo, AmountCent: amountCent, Channel: channel, Subject: subject, UserID: current(r).ID, Scene: scene}) if gatewayErr != nil { fail(w, http.StatusBadGateway, 50003, "支付网关下单失败") return @@ -176,11 +218,11 @@ func (a *App) payOrder(w http.ResponseWriter, r *http.Request) { encoded, _ := json.Marshal(gatewayResult.AppPayload) payloadJSON = string(encoded) } - if _, err = a.db.ExecContext(r.Context(), `UPDATE orders SET provider_order_no=?,checkout_url=?,payment_payload=? WHERE id=? AND user_id=? AND status='CREATED' AND provider_order_no=''`, gatewayResult.ProviderOrderNo, gatewayResult.CheckoutURL, payloadJSON, id, current(r).ID); err != nil { + if _, err = a.db.ExecContext(r.Context(), `UPDATE orders SET provider_order_no=?,checkout_url=?,payment_payload=?,payment_scene=? WHERE id=? AND user_id=? AND status='CREATED'`, gatewayResult.ProviderOrderNo, gatewayResult.CheckoutURL, payloadJSON, scene, id, current(r).ID); err != nil { fail(w, http.StatusInternalServerError, 50001, "保存支付信息失败") return } - reply(w, map[string]any{"success": true, "mode": mode, "status": status, "providerOrderNo": gatewayResult.ProviderOrderNo, "checkoutUrl": gatewayResult.CheckoutURL, "appPayload": gatewayResult.AppPayload}) + reply(w, map[string]any{"success": true, "mode": mode, "status": status, "scene": scene, "providerOrderNo": gatewayResult.ProviderOrderNo, "checkoutUrl": gatewayResult.CheckoutURL, "appPayload": gatewayResult.AppPayload}) return } if a.config.Environment == "production" { diff --git a/im/backend/internal/app/order_products.go b/im/backend/internal/app/order_products.go index 261361935..fd3da9013 100644 --- a/im/backend/internal/app/order_products.go +++ b/im/backend/internal/app/order_products.go @@ -41,23 +41,22 @@ func (a *App) applyPaidOrderTx(ctx context.Context, tx *sql.Tx, orderID, userID if owner != userID || gross != amountCent { return fmt.Errorf("payment does not match the feeding task") } - if status == "ESCROWED" { + if status == feedStatusEscrowed { return nil } - if status != "CREATED" { + if status != feedStatusCreated { return fmt.Errorf("feeding task status does not allow payment") } _, err := tx.ExecContext(ctx, `UPDATE pet_feed_tasks SET status='ESCROWED',order_id=? WHERE id=? AND status='CREATED'`, orderID, productID) return err case productPetDeposit: - result, err := tx.ExecContext(ctx, `UPDATE pet_sitters SET deposit_cent=deposit_cent+? WHERE user_id=?`, amountCent, userID) - if err != nil { + // 走统一的记账函数:线上缴纳和客服登记必须落在同一本账上, + // 否则历史会缺一半,出了理赔说不清这笔钱是怎么来的。 + if _, err := writeDepositEntry(ctx, tx, userID, int64(amountCent), + depositSourceOrder, "线上缴纳保证金", 0, orderID, 0); err != nil { return err } - if affected, _ := result.RowsAffected(); affected == 0 { - return fmt.Errorf("sitter profile does not exist") - } return nil } return fmt.Errorf("unknown product type: %s", productType) @@ -81,18 +80,32 @@ func (a *App) revokePaidOrderTx(ctx context.Context, tx *sql.Tx, orderID, userID if err := tx.QueryRowContext(ctx, `SELECT status FROM pet_feed_tasks WHERE id=? FOR UPDATE`, productID).Scan(&status); err != nil { return fmt.Errorf("feeding task does not exist") } - if status == "SETTLED" || status == "ARBITRATED" { + if feedStatusSettledOrArbitrated(status) { return fmt.Errorf("feeding task is already settled; refund it through the dispute flow") } - if status == "REFUNDED" { + if status == feedStatusRefunded { return nil } _, err := tx.ExecContext(ctx, `UPDATE pet_feed_tasks SET status='REFUNDED',confirm_due_at=NULL WHERE id=?`, productID) return err case productPetDeposit: - // Never below zero: part of a deposit may already have paid a claim. - _, err := tx.ExecContext(ctx, `UPDATE pet_sitters SET deposit_cent=GREATEST(CAST(deposit_cent AS SIGNED)-?,0) WHERE user_id=?`, amountCent, userID) + // 退款把保证金收回来。writeDepositEntry 不允许扣成负数—— + // 这笔钱可能已经赔付掉一部分,剩多少就只能收回多少。 + var held int64 + if err := tx.QueryRowContext(ctx, `SELECT deposit_cent FROM pet_sitters WHERE user_id=?`, + userID).Scan(&held); err != nil { + return err + } + back := int64(amountCent) + if back > held { + back = held + } + if back == 0 { + return nil + } + _, err := writeDepositEntry(ctx, tx, userID, -back, depositSourceRefund, + "保证金订单退款,收回已缴纳部分", 0, orderID, 0) return err } return fmt.Errorf("unknown product type: %s", productType) @@ -179,7 +192,7 @@ func (a *App) createDepositOrder(w http.ResponseWriter, r *http.Request) { } var paid int if a.db.QueryRowContext(r.Context(), `SELECT deposit_cent FROM pet_sitters WHERE user_id=?`, current(r).ID).Scan(&paid) != nil { - fail(w, http.StatusForbidden, 30003, "请先申请成为喂养者") + failGate(w, http.StatusForbidden, 30003, "请先申请成为喂养者", gateSitter, "去申请") return } required := a.configInt(r.Context(), "pet.sitter_deposit_cent", 20000) diff --git a/im/backend/internal/app/payment_gateway.go b/im/backend/internal/app/payment_gateway.go index 103f121fa..2f93e70a3 100644 --- a/im/backend/internal/app/payment_gateway.go +++ b/im/backend/internal/app/payment_gateway.go @@ -24,6 +24,9 @@ type paymentGatewayOrder struct { Channel string Subject string UserID int64 + // Scene 是 h5/app/mp。老客户端不上报时为空,此时不往网关传, + // 保持升级前的行为,不能让已经装机的版本付不了款。 + Scene string } type paymentGatewayResult struct { @@ -65,12 +68,26 @@ func (a *App) createGatewayPayment(ctx context.Context, order paymentGatewayOrde if !a.paymentGatewayConfigured(ctx) { return paymentGatewayResult{}, fmt.Errorf("payment gateway is not completely configured") } - payload, err := json.Marshal(map[string]any{ + requestBody := map[string]any{ "orderNo": order.OrderNo, "amountCent": order.AmountCent, "currency": "CNY", "channel": order.Channel, "subject": order.Subject, "userId": order.UserID, "notifyUrl": a.configPlain(ctx, "payment.gateway.notify_url", ""), - "returnUrl": a.configPlain(ctx, "payment.gateway.return_url", ""), - }) + } + if order.Scene != "" { + // 场景决定网关该出哪种收银台:H5 给跳转地址,App 和小程序给 SDK 参数。 + requestBody["scene"] = order.Scene + if appID := a.paymentSceneAppID(ctx, order.Channel, order.Scene); appID != "" { + requestBody["appId"] = appID + } + // 只有网页支付才需要"付完跳回哪里",App 和小程序回的是自己的页面。 + if order.Scene == paymentSceneH5 { + requestBody["returnUrl"] = a.configPlain(ctx, "payment.gateway.return_url", "") + } + } else { + // 没上报场景的老客户端,行为和升级前完全一致。 + requestBody["returnUrl"] = a.configPlain(ctx, "payment.gateway.return_url", "") + } + payload, err := json.Marshal(requestBody) if err != nil { return paymentGatewayResult{}, err } diff --git a/im/backend/internal/app/pet_admin_crud.go b/im/backend/internal/app/pet_admin_crud.go index 7c47a9061..9f556ec1b 100644 --- a/im/backend/internal/app/pet_admin_crud.go +++ b/im/backend/internal/app/pet_admin_crud.go @@ -335,12 +335,12 @@ func (a *App) adminUpdateFeedTask(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusBadRequest, 20001, "取消任务必须写明原因") return } - if status == "SETTLED" || status == "REFUNDED" || status == "CANCELLED" { + if status == feedStatusSettled || status == feedStatusRefunded || status == feedStatusCancelled { fail(w, http.StatusBadRequest, 20001, "这个状态的任务不能取消") return } // 钱还在托管里就先退,再取消;否则会留下一笔没有归属的钱。 - if gross > refunded && status != "CREATED" { + if gross > refunded && status != feedStatusCreated { fail(w, http.StatusBadRequest, 20001, "请先把托管中的金额退给主人,再取消任务") return } diff --git a/im/backend/internal/app/pet_adoption.go b/im/backend/internal/app/pet_adoption.go index e268ee396..65acb1b87 100644 --- a/im/backend/internal/app/pet_adoption.go +++ b/im/backend/internal/app/pet_adoption.go @@ -20,6 +20,7 @@ type adoptionView struct { PetID int64 `json:"petId"` OwnerUserID int64 `json:"ownerUserId"` OwnerName string `json:"ownerName"` + OwnerAvatar string `json:"ownerAvatar"` PetName string `json:"petName"` Species string `json:"species"` SpeciesLabel string `json:"speciesLabel"` @@ -93,6 +94,7 @@ func (a *App) createAdoption(w http.ResponseWriter, r *http.Request) { CityName string `json:"cityName"` Reason string `json:"reason"` Requirements string `json:"requirements"` + AcceptPhone bool `json:"acceptPhone"` } if decode(r, &req) != nil || req.PetID == 0 { fail(w, http.StatusBadRequest, 20001, "送养信息格式错误") @@ -133,8 +135,8 @@ func (a *App) createAdoption(w http.ResponseWriter, r *http.Request) { return } reviewReason := a.adoptionReviewReason(r.Context(), current(r).ID, req.Reason+" "+req.Requirements) - result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_adoptions(pet_id,owner_user_id,city_code,city_name,reason,requirements,review_reason) - VALUES(?,?,?,?,?,?,?)`, req.PetID, current(r).ID, strings.TrimSpace(req.CityCode), req.CityName, req.Reason, req.Requirements, reviewReason) + result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_adoptions(pet_id,owner_user_id,city_code,city_name,reason,requirements,review_reason,accept_phone) + VALUES(?,?,?,?,?,?,?,?)`, req.PetID, current(r).ID, strings.TrimSpace(req.CityCode), req.CityName, req.Reason, req.Requirements, reviewReason, req.AcceptPhone) if err != nil { fail(w, http.StatusInternalServerError, 50001, "发布失败") return @@ -143,7 +145,7 @@ func (a *App) createAdoption(w http.ResponseWriter, r *http.Request) { reply(w, map[string]any{"id": id, "pendingReview": true, "reviewReason": reviewReason}) } -const adoptionColumns = `a.id,a.pet_id,a.owner_user_id,owner.nickname,a.city_name,a.reason,a.requirements,a.status,a.moderation_status,a.application_count,a.created_at, +const adoptionColumns = `a.id,a.pet_id,a.owner_user_id,owner.nickname,owner.avatar_url,a.city_name,a.reason,a.requirements,a.status,a.moderation_status,a.application_count,a.created_at, p.name,p.species,p.breed,p.gender,p.birthday,p.neutered,p.vaccinated_at,p.avatar_url` func (a *App) scanAdoptions(ctx context.Context, rows *sql.Rows, viewer int64) []adoptionView { @@ -155,12 +157,13 @@ func (a *App) scanAdoptions(ctx context.Context, rows *sql.Rows, viewer int64) [ var birthday, vaccinated sql.NullTime var created time.Time var neutered int - if rows.Scan(&item.ID, &item.PetID, &item.OwnerUserID, &item.OwnerName, &item.CityName, &item.Reason, + if rows.Scan(&item.ID, &item.PetID, &item.OwnerUserID, &item.OwnerName, &item.OwnerAvatar, &item.CityName, &item.Reason, &item.Requirements, &item.Status, &item.Moderation, &item.Applications, &created, &item.PetName, &item.Species, &item.Breed, &item.Gender, &birthday, &neutered, &vaccinated, &item.Avatar) != nil { continue } item.SpeciesLabel = petSpecies[item.Species] + item.OwnerAvatar = avatarThumbnailURL(item.OwnerAvatar) item.Neutered = neutered == 1 if birthday.Valid { item.AgeMonths = monthsSince(birthday.Time) @@ -231,6 +234,36 @@ func (a *App) adoptions(w http.ResponseWriter, r *http.Request) { where += " AND p.species=?" args = append(args, species) } + if breed := strings.TrimSpace(r.URL.Query().Get("breed")); breed != "" { + where += " AND p.breed=?" + args = append(args, breed) + } + if gender := strings.TrimSpace(r.URL.Query().Get("gender")); gender == "1" || gender == "2" { + where += " AND p.gender=?" + args = append(args, gender) + } + // 年龄按月龄分档:幼年 0-11、成年 12-83、老年 84 以上。 + switch strings.TrimSpace(r.URL.Query().Get("age")) { + case "baby": + where += " AND p.birthday IS NOT NULL AND TIMESTAMPDIFF(MONTH,p.birthday,CURDATE())<12" + case "adult": + where += " AND p.birthday IS NOT NULL AND TIMESTAMPDIFF(MONTH,p.birthday,CURDATE()) BETWEEN 12 AND 83" + case "senior": + where += " AND p.birthday IS NOT NULL AND TIMESTAMPDIFF(MONTH,p.birthday,CURDATE())>=84" + } + if strings.TrimSpace(r.URL.Query().Get("vaccinated")) == "1" { + where += " AND p.vaccinated_at IS NOT NULL" + } + if neutered := strings.TrimSpace(r.URL.Query().Get("neutered")); neutered == "0" || neutered == "1" { + where += " AND p.neutered=?" + args = append(args, neutered) + } + if keyword := strings.TrimSpace(r.URL.Query().Get("keyword")); keyword != "" { + where += " AND (p.name LIKE ? OR p.breed LIKE ? OR a.reason LIKE ?)" + like := "%" + keyword + "%" + args = append(args, like, like, like) + } + } query := `SELECT ` + adoptionColumns + ` FROM pet_adoptions a JOIN pets p ON p.id=a.pet_id JOIN user_profiles owner ON owner.user_id=a.owner_user_id` + where + @@ -257,7 +290,9 @@ func (a *App) adoptionDetail(w http.ResponseWriter, r *http.Request) { } rows, queryErr := a.db.QueryContext(r.Context(), `SELECT `+adoptionColumns+` FROM pet_adoptions a JOIN pets p ON p.id=a.pet_id JOIN user_profiles owner ON owner.user_id=a.owner_user_id - WHERE a.id=? AND a.deleted_at IS NULL AND (a.owner_user_id=? OR (a.status<>0 AND a.moderation_status=1))`, id, current(r).ID) + WHERE a.id=? AND a.deleted_at IS NULL AND (a.owner_user_id=? + OR EXISTS(SELECT 1 FROM pet_adoption_applications ap WHERE ap.adoption_id=a.id AND ap.applicant_user_id=?) + OR (a.status<>0 AND a.moderation_status=1))`, id, current(r).ID, current(r).ID) if queryErr != nil { fail(w, http.StatusInternalServerError, 50001, "查询失败") return @@ -276,6 +311,9 @@ func (a *App) adoptionDetail(w http.ResponseWriter, r *http.Request) { var note string _ = a.db.QueryRowContext(r.Context(), `SELECT moderation_note FROM pet_adoptions WHERE id=?`, id).Scan(¬e) view["moderationNote"] = note + } else { + // 页面要据此决定画哪个按钮:申请、等回应、看号码,还是"对方不接受电话"。 + view["contact"] = a.contactState(r.Context(), "adoption", id, current(r).ID, items[0].OwnerUserID) } reply(w, view) } @@ -443,8 +481,20 @@ func (a *App) decideAdoption(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusInternalServerError, 50001, "操作失败") return } - a.notifyUser(r.Context(), applicant, "system", "领养申请通过", "送养人选择了你,请尽快联系并当面确认", "adoption", id) - reply(w, map[string]bool{"success": true}) + // 以前这里只发一句"请尽快联系",但产品里根本没有联系的入口。现在直接 + // 把会话建好,双方进去就能说话。 + conversation, convErr := a.ensureDirectConversation(r.Context(), owner, applicant) + if convErr == nil { + _, _ = a.db.ExecContext(r.Context(), `UPDATE pet_adoptions SET conversation_id=? WHERE id=?`, conversation, id) + } + notice := "送养人选择了你,已经为你们建好会话,可以直接聊接送细节" + if convErr != nil { + notice = "送养人选择了你,请在这条送养信息里联系对方" + } + a.notifyUser(r.Context(), applicant, "system", "领养申请通过", notice, "adoption", id) + a.notifyUser(r.Context(), owner, "system", "你已选定领养人", + "已经为你和领养人建好会话,交接完成后记得来做回访", "adoption", id) + reply(w, map[string]any{"success": true, "conversationId": conversation}) } func (a *App) closeAdoption(w http.ResponseWriter, r *http.Request) { diff --git a/im/backend/internal/app/pet_feed.go b/im/backend/internal/app/pet_feed.go index 3c163a586..cc01aa463 100644 --- a/im/backend/internal/app/pet_feed.go +++ b/im/backend/internal/app/pet_feed.go @@ -210,7 +210,8 @@ func (a *App) applySitter(w http.ResponseWriter, r *http.Request) { } // Entering someone's home is not something an anonymous account may do. if _, verified := a.verifiedRealName(r.Context(), current(r).ID); !verified { - fail(w, http.StatusForbidden, 30003, "上门服务需要先完成实名认证") + failGate(w, http.StatusForbidden, 30003, "上门服务需要先完成实名认证", + gateVerification, "去认证") return } certs, _ := json.Marshal(req.Certs) @@ -485,7 +486,7 @@ func (a *App) feedTaskDetail(w http.ResponseWriter, r *http.Request) { view := map[string]any{"task": task} // The exact address and phone number are released only to the person who has // to go there, and only once they have been chosen. - if task.IsSitter && (task.Status == "ASSIGNED" || task.Status == "SERVING" || task.Status == "COMPLETED") { + if task.IsSitter && (task.Status == feedStatusAssigned || task.Status == feedStatusServing || task.Status == feedStatusCompleted) { address := a.decryptAddress(r.Context(), task.ID) view["address"] = address } @@ -632,15 +633,17 @@ func (a *App) applyFeedTask(w http.ResponseWriter, r *http.Request) { var status int var deposit int if a.db.QueryRowContext(r.Context(), `SELECT status,deposit_cent FROM pet_sitters WHERE user_id=?`, current(r).ID).Scan(&status, &deposit) != nil { - fail(w, http.StatusForbidden, 30003, "请先申请成为喂养者") + failGate(w, http.StatusForbidden, 30003, "请先申请成为喂养者", gateSitter, "去申请") return } if status != 1 { - fail(w, http.StatusForbidden, 30003, "喂养者资格审核通过后才能接单") + failGate(w, http.StatusForbidden, 30003, "喂养者资格审核通过后才能接单", + gateSitter, "看审核进度") return } if required := a.configInt(r.Context(), "pet.sitter_deposit_cent", 20000); deposit < required { - fail(w, http.StatusForbidden, 30003, fmt.Sprintf("请先缴纳 %.0f 元保证金", float64(required)/100)) + failGate(w, http.StatusForbidden, 30003, + fmt.Sprintf("请先缴纳 %.0f 元保证金", float64(required)/100), gateDeposit, "去缴纳") return } var owner int64 @@ -653,7 +656,7 @@ func (a *App) applyFeedTask(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusBadRequest, 20001, "不能接自己发布的任务") return } - if taskStatus != "ESCROWED" { + if taskStatus != feedStatusEscrowed { fail(w, http.StatusBadRequest, 20001, "任务已经不接受报名") return } @@ -699,7 +702,7 @@ func (a *App) assignFeedTask(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusNotFound, 30001, "任务不存在") return } - if status != "ESCROWED" { + if status != feedStatusEscrowed { fail(w, http.StatusBadRequest, 20001, "任务状态不允许选定") return } diff --git a/im/backend/internal/app/pet_feed_admin.go b/im/backend/internal/app/pet_feed_admin.go index c84232695..0309ec8b6 100644 --- a/im/backend/internal/app/pet_feed_admin.go +++ b/im/backend/internal/app/pet_feed_admin.go @@ -22,6 +22,9 @@ func (a *App) adminSitters(w http.ResponseWriter, r *http.Request) { where += " AND (profile.nickname LIKE ? OR s.user_id=?)" args = append(args, "%"+keyword+"%", keyword) } + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM pet_sitters s + JOIN user_profiles profile ON profile.user_id=s.user_id`+where, args...).Scan(&total) rows, err := a.db.QueryContext(r.Context(), `SELECT s.user_id,profile.nickname,profile.avatar_url,s.city_name,s.radius_m,s.intro,s.certs_json, s.deposit_cent,s.rating_total,s.rating_count,s.completed_count,s.cancelled_count,s.status,s.status_note,s.created_at FROM pet_sitters s JOIN user_profiles profile ON profile.user_id=s.user_id`+where+` ORDER BY s.status ASC,s.user_id DESC LIMIT ? OFFSET ?`, @@ -53,7 +56,7 @@ func (a *App) adminSitters(w http.ResponseWriter, r *http.Request) { "status": status, "statusNote": note, "createdAt": created.Format(time.RFC3339), }) } - reply(w, map[string]any{"items": items}) + reply(w, map[string]any{"items": items, "total": total}) } func (a *App) adminReviewSitter(w http.ResponseWriter, r *http.Request) { @@ -116,9 +119,23 @@ func (a *App) adminFeedTasks(w http.ResponseWriter, r *http.Request) { where += " AND t.city_code=?" args = append(args, city) } + if keyword := strings.TrimSpace(r.URL.Query().Get("keyword")); keyword != "" { + where += " AND (owner.nickname LIKE ? OR sitter.nickname LIKE ? OR p.name LIKE ? OR t.id=?)" + like := "%" + keyword + "%" + args = append(args, like, like, like, keyword) + } + // 只看需要人工处理的:没人接、超时未确认、还押着钱。 + if r.URL.Query().Get("pending") == "1" { + where += " AND (t.status='ESCROWED' OR (t.status='COMPLETED' AND t.confirm_due_at 0 { - if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET refunded_cent=refunded_cent+?, - status=IF(refunded_cent+?>=amount_cent,'REFUNDED',status) WHERE id=?`, amount, amount, orderID.Int64); err != nil { - fail(w, http.StatusInternalServerError, 50001, "退款记账失败") - return - } - } - // 退完并且还没结算的任务就此关闭;部分退款保留原状态,服务还得继续。 - if refunded+amount >= gross && !settled { - if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='REFUNDED',confirm_due_at=NULL WHERE id=?`, id); err != nil { - fail(w, http.StatusInternalServerError, 50001, "退款记账失败") - return - } - } if tx.Commit() != nil { fail(w, http.StatusInternalServerError, 50001, "退款记账失败") return diff --git a/im/backend/internal/app/pet_feed_service.go b/im/backend/internal/app/pet_feed_service.go index 138eb1bdc..ae89dbaa0 100644 --- a/im/backend/internal/app/pet_feed_service.go +++ b/im/backend/internal/app/pet_feed_service.go @@ -64,7 +64,7 @@ func (a *App) checkinFeedVisit(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusForbidden, 30003, "只有接单的喂养者可以打卡") return } - if status != "ASSIGNED" && status != "SERVING" { + if status != feedStatusAssigned && status != feedStatusServing { fail(w, http.StatusBadRequest, 20001, "当前任务状态不能打卡") return } @@ -280,7 +280,7 @@ func (a *App) settleFeedTask(ctx context.Context, taskID int64, reason string) e if err = tx.QueryRowContext(ctx, `SELECT sitter_user_id,payout_cent,status FROM pet_feed_tasks WHERE id=? FOR UPDATE`, taskID).Scan(&sitter, &payout, &status); err != nil { return err } - if status != "COMPLETED" && status != "ARBITRATED" { + if status != feedStatusCompleted && status != feedStatusArbitrated { return fmt.Errorf("任务状态不允许结算") } if !sitter.Valid || sitter.Int64 == 0 || payout <= 0 { @@ -319,7 +319,7 @@ func (a *App) confirmFeedTask(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusForbidden, 30003, "只有发布任务的人可以确认") return } - if status != "COMPLETED" { + if status != feedStatusCompleted { fail(w, http.StatusBadRequest, 20001, "任务状态不允许确认") return } @@ -418,13 +418,13 @@ func (a *App) cancelFeedTask(w http.ResponseWriter, r *http.Request) { } // Once someone has been sent to your door, cancelling is a negotiation, not // a button. - if status != "CREATED" && status != "ESCROWED" { + if status != feedStatusCreated && status != feedStatusEscrowed { fail(w, http.StatusBadRequest, 20001, "已经选定喂养者的任务请通过申诉处理") return } - next := "CANCELLED" - if status == "ESCROWED" { - next = "REFUNDED" + next := feedStatusCancelled + if status == feedStatusEscrowed { + next = feedStatusRefunded } if _, err = a.db.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status=? WHERE id=? AND status=?`, next, id, status); err != nil { fail(w, http.StatusInternalServerError, 50001, "操作失败") @@ -464,7 +464,7 @@ func (a *App) raiseFeedDispute(w http.ResponseWriter, r *http.Request) { } // After settlement the money is already in someone's account; that is a // refund conversation with support, not an escrow decision. - if status != "ASSIGNED" && status != "SERVING" && status != "COMPLETED" { + if status != feedStatusAssigned && status != feedStatusServing && status != feedStatusCompleted { fail(w, http.StatusBadRequest, 20001, "当前状态不能发起申诉") return } @@ -532,7 +532,7 @@ func (a *App) reviewFeedTask(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusNotFound, 30001, "任务不存在") return } - if status != "COMPLETED" && status != "SETTLED" && status != "ARBITRATED" { + if status != feedStatusCompleted && status != feedStatusSettled && status != feedStatusArbitrated { fail(w, http.StatusBadRequest, 20001, "服务结束后才能评价") return } @@ -594,7 +594,8 @@ func (a *App) sitterReviews(w http.ResponseWriter, r *http.Request) { _, size, offset := pageOptions(r) rows, queryErr := a.db.QueryContext(r.Context(), `SELECT rv.punctual,rv.accurate,rv.communication,rv.content,rv.created_at,profile.nickname,profile.avatar_url FROM pet_feed_reviews rv JOIN user_profiles profile ON profile.user_id=rv.from_user_id - WHERE rv.to_user_id=? AND rv.visible_at IS NOT NULL ORDER BY rv.id DESC LIMIT ? OFFSET ?`, userID, size, offset) + WHERE rv.to_user_id=? AND rv.visible_at IS NOT NULL AND rv.hidden_at IS NULL + ORDER BY rv.id DESC LIMIT ? OFFSET ?`, userID, size, offset) if queryErr != nil { fail(w, http.StatusInternalServerError, 50001, "查询评价失败") return diff --git a/im/backend/internal/app/pet_mating.go b/im/backend/internal/app/pet_mating.go index bc7ed03c3..154c8fa2d 100644 --- a/im/backend/internal/app/pet_mating.go +++ b/im/backend/internal/app/pet_mating.go @@ -23,6 +23,7 @@ func (a *App) createMatingListing(w http.ResponseWriter, r *http.Request) { CityName string `json:"cityName"` Expectation string `json:"expectation"` VaccineMedia string `json:"vaccineMedia"` + AcceptPhone bool `json:"acceptPhone"` } if decode(r, &req) != nil || req.PetID == 0 { fail(w, http.StatusBadRequest, 20001, "配种信息格式错误") @@ -65,8 +66,17 @@ func (a *App) createMatingListing(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusBadRequest, 20001, "这只宠物已经发布过配种信息") return } - result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_mating_listings(pet_id,owner_user_id,city_code,city_name,expectation,vaccine_media) - VALUES(?,?,?,?,?,?)`, req.PetID, current(r).ID, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.CityName), req.Expectation, strings.TrimSpace(req.VaccineMedia)) + // 配种的期望描述之前完全没过词库,留微信号、写买卖字眼都拦不住。 + reviewReason := "" + if word, action := a.matchKeyword(r.Context(), "mating", req.Expectation); word != "" { + if action == "block" { + fail(w, http.StatusBadRequest, 20001, "内容里有不能发布的词,请修改后再试") + return + } + reviewReason = "命中敏感词:" + word + } + result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_mating_listings(pet_id,owner_user_id,city_code,city_name,expectation,vaccine_media,accept_phone,review_reason) + VALUES(?,?,?,?,?,?,?,?)`, req.PetID, current(r).ID, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.CityName), req.Expectation, strings.TrimSpace(req.VaccineMedia), req.AcceptPhone, reviewReason) if err != nil { fail(w, http.StatusInternalServerError, 50001, "发布失败") return @@ -96,6 +106,32 @@ func (a *App) matingListings(w http.ResponseWriter, r *http.Request) { where += " AND p.species=?" args = append(args, species) } + if breed := strings.TrimSpace(r.URL.Query().Get("breed")); breed != "" { + where += " AND p.breed=?" + args = append(args, breed) + } + if gender := strings.TrimSpace(r.URL.Query().Get("gender")); gender == "1" || gender == "2" { + where += " AND p.gender=?" + args = append(args, gender) + } + // 年龄按月龄分档:幼年 0-11、成年 12-83、老年 84 以上。 + switch strings.TrimSpace(r.URL.Query().Get("age")) { + case "baby": + where += " AND p.birthday IS NOT NULL AND TIMESTAMPDIFF(MONTH,p.birthday,CURDATE())<12" + case "adult": + where += " AND p.birthday IS NOT NULL AND TIMESTAMPDIFF(MONTH,p.birthday,CURDATE()) BETWEEN 12 AND 83" + case "senior": + where += " AND p.birthday IS NOT NULL AND TIMESTAMPDIFF(MONTH,p.birthday,CURDATE())>=84" + } + if strings.TrimSpace(r.URL.Query().Get("vaccinated")) == "1" { + where += " AND p.vaccinated_at IS NOT NULL" + } + if keyword := strings.TrimSpace(r.URL.Query().Get("keyword")); keyword != "" { + where += " AND (p.name LIKE ? OR p.breed LIKE ? OR m.expectation LIKE ?)" + like := "%" + keyword + "%" + args = append(args, like, like, like) + } + } rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,m.pet_id,m.owner_user_id,owner.nickname,m.city_name,m.expectation,m.status,m.moderation_status,m.created_at, p.name,p.species,p.breed,p.gender,p.birthday,p.avatar_url,p.vaccinated_at diff --git a/im/backend/internal/app/pets.go b/im/backend/internal/app/pets.go index b59d05189..8c9363cfc 100644 --- a/im/backend/internal/app/pets.go +++ b/im/backend/internal/app/pets.go @@ -101,6 +101,10 @@ func (a *App) validatePet(ctx context.Context, req *petRequest, ownerID int64) e if len([]rune(req.Breed)) > 50 || len([]rune(req.Temperament)) > 200 || len(req.ChipNo) > 40 { return fmt.Errorf("品种、芯片号或性格描述过长") } + // 性格备注是自由文本,之前完全没过词库,留微信号和买卖字眼都拦不住。 + if word, action := a.matchKeyword(ctx, "pet", req.Name+" "+req.Temperament); word != "" && action == "block" { + return fmt.Errorf("档案里有不能发布的内容,请修改后再试") + } if req.Gender < 0 || req.Gender > 2 { return fmt.Errorf("性别取值无效") } @@ -300,6 +304,9 @@ func (a *App) updatePet(w http.ResponseWriter, r *http.Request) { // exist even if the request dies midway. func (a *App) writePet(r *http.Request, id int64, req petRequest) (int64, error) { birthday, vaccinated, dewormed := req.birthday, req.vaccinated, req.dewormed + // "土猫" 和 "中华田园猫" 存成同一个词,品种才筛得动、统计才有意义。 + // 认不出来的写法原样保留——那是用户自己的字,不该被我们改掉。 + req.Breed = a.normaliseBreed(r.Context(), req.Species, req.Breed) tx, err := a.db.BeginTx(r.Context(), nil) if err != nil { return 0, fmt.Errorf("保存宠物档案失败") diff --git a/im/backend/internal/app/social.go b/im/backend/internal/app/social.go index be101eab1..08fceb05c 100644 --- a/im/backend/internal/app/social.go +++ b/im/backend/internal/app/social.go @@ -786,8 +786,11 @@ func (a *App) createReport(w http.ResponseWriter, r *http.Request) { req.TargetType = strings.ToLower(strings.TrimSpace(req.TargetType)) req.Reason = strings.ToLower(strings.TrimSpace(req.Reason)) req.Description = strings.TrimSpace(req.Description) - allowedTargets := map[string]bool{"user": true, "post": true, "comment": true, "message": true} - allowedReasons := map[string]bool{"fraud": true, "harassment": true, "pornography": true, "advertising": true, "violence": true, "minor_safety": true, "privacy": true, "other": true} + // 送养和配种是最需要举报的场景:倒卖、虐待、诈骗都发生在这儿,之前反而报不了。 + allowedTargets := map[string]bool{"user": true, "post": true, "comment": true, "message": true, + "adoption": true, "mating": true} + allowedReasons := map[string]bool{"fraud": true, "harassment": true, "pornography": true, "advertising": true, + "violence": true, "minor_safety": true, "privacy": true, "animal_abuse": true, "pet_trade": true, "other": true} if !allowedTargets[req.TargetType] || !allowedReasons[req.Reason] || len([]rune(req.Description)) > 1000 || len(req.Evidence) > 6 { fail(w, 400, 20001, "举报类型、原因或证据无效") return @@ -804,6 +807,12 @@ func (a *App) createReport(w http.ResponseWriter, r *http.Request) { _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM post_comments c JOIN posts p ON p.id=c.post_id WHERE c.id=? AND c.status=1 AND c.user_id<>? AND p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id))))`, req.TargetID, current(r).ID, current(r).ID, current(r).ID).Scan(&targetExists) case "message": _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM im_messages message JOIN im_conversation_members member ON member.conversation_id=message.conversation_id AND member.user_id=? AND member.status=1 WHERE message.id=? AND message.sender_id<>?)`, current(r).ID, req.TargetID, current(r).ID).Scan(&targetExists) + case "adoption": + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM pet_adoptions WHERE id=? AND owner_user_id<>? + AND deleted_at IS NULL AND moderation_status=1)`, req.TargetID, current(r).ID).Scan(&targetExists) + case "mating": + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM pet_mating_listings WHERE id=? AND owner_user_id<>? + AND deleted_at IS NULL AND moderation_status=1)`, req.TargetID, current(r).ID).Scan(&targetExists) } if targetExists != 1 { fail(w, http.StatusNotFound, 30001, "举报目标不存在、不可访问或属于当前账号") diff --git a/im/backend/internal/app/storage_providers.go b/im/backend/internal/app/storage_providers.go index e640a9ef4..b048ea616 100644 --- a/im/backend/internal/app/storage_providers.go +++ b/im/backend/internal/app/storage_providers.go @@ -105,7 +105,7 @@ func (a *App) validateStorageProviderConfig(ctx context.Context) error { } if provider == "local" { - if err := validateLocalStorageDirectory(a.configPlain(ctx, "storage.local.directory", a.config.MediaDir)); err != nil { + if err := validateLocalStorageDirectory(a.localStorageDirectory(ctx)); err != nil { return err } baseURL := strings.TrimSpace(a.configPlain(ctx, "storage.local.public_base_url", "")) @@ -234,7 +234,7 @@ func (a *App) newMediaObjectStorage(ctx context.Context) (mediaObjectStorage, fu func (a *App) newMediaObjectStorageForProvider(ctx context.Context, provider, recordedBucket string) (mediaObjectStorage, func(), error) { provider = strings.TrimSpace(provider) if provider == "local" { - directory := a.configPlain(ctx, "storage.local.directory", a.config.MediaDir) + directory := a.localStorageDirectory(ctx) if err := validateLocalStorageDirectory(directory); err != nil { return nil, func() {}, err }