更新
This commit is contained in:
@@ -1 +1 @@
|
||||
57dce2e3bd928effae6f9684d54fc4f44a1865a1ee257f92d3912bc96e9ea5fd
|
||||
fefa866e36c754224a89fa9f096d732bb526fb931113a92364af6c5a9b14b1bd
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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-- {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<NOW()) OR t.status='DISPUTED')"
|
||||
}
|
||||
var total int64
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM pet_feed_tasks t JOIN pets p ON p.id=t.pet_id
|
||||
JOIN pet_addresses addr ON addr.id=t.address_id JOIN user_profiles owner ON owner.user_id=t.owner_user_id
|
||||
LEFT JOIN user_profiles sitter ON sitter.user_id=t.sitter_user_id`+where, args...).Scan(&total)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT t.id,t.owner_user_id,owner.nickname,COALESCE(t.sitter_user_id,0),COALESCE(sitter.nickname,''),
|
||||
p.name,addr.city_name,t.start_date,t.end_date,t.gross_cent,t.platform_fee_cent,t.payout_cent,t.status,
|
||||
t.completed_visits,t.total_visits,t.confirm_due_at,t.settled_at,t.created_at
|
||||
t.completed_visits,t.total_visits,t.confirm_due_at,t.settled_at,t.created_at,
|
||||
t.tip_cent,t.refunded_cent,t.applications_count,COALESCE(t.conversation_id,0),t.price_cent,t.visits_per_day,t.city_code
|
||||
FROM pet_feed_tasks t JOIN pets p ON p.id=t.pet_id JOIN pet_addresses addr ON addr.id=t.address_id
|
||||
JOIN user_profiles owner ON owner.user_id=t.owner_user_id
|
||||
LEFT JOIN user_profiles sitter ON sitter.user_id=t.sitter_user_id`+where+` ORDER BY t.id DESC LIMIT ? OFFSET ?`,
|
||||
@@ -132,19 +149,24 @@ func (a *App) adminFeedTasks(w http.ResponseWriter, r *http.Request) {
|
||||
for rows.Next() {
|
||||
var id, ownerID, sitterID int64
|
||||
var ownerName, sitterName, petName, cityName, status string
|
||||
var gross, fee, payout int64
|
||||
var done, total int
|
||||
var gross, fee, payout, tip, refunded, price int64
|
||||
var done, visits, applications, perDay int
|
||||
var conversation int64
|
||||
var cityCode string
|
||||
var start, end, created time.Time
|
||||
var confirmDue, settled sql.NullTime
|
||||
if rows.Scan(&id, &ownerID, &ownerName, &sitterID, &sitterName, &petName, &cityName, &start, &end,
|
||||
&gross, &fee, &payout, &status, &done, &total, &confirmDue, &settled, &created) != nil {
|
||||
&gross, &fee, &payout, &status, &done, &visits, &confirmDue, &settled, &created,
|
||||
&tip, &refunded, &applications, &conversation, &price, &perDay, &cityCode) != nil {
|
||||
continue
|
||||
}
|
||||
item := map[string]any{
|
||||
"id": id, "ownerUserId": ownerID, "ownerName": ownerName, "sitterUserId": sitterID, "sitterName": sitterName,
|
||||
"petName": petName, "cityName": cityName, "startDate": start.Format("2006-01-02"), "endDate": end.Format("2006-01-02"),
|
||||
"grossCent": gross, "platformFeeCent": fee, "payoutCent": payout, "status": status,
|
||||
"completedVisits": done, "totalVisits": total, "createdAt": created.Format(time.RFC3339),
|
||||
"completedVisits": done, "totalVisits": visits, "createdAt": created.Format(time.RFC3339),
|
||||
"tipCent": tip, "refundedCent": refunded, "applicationsCount": applications,
|
||||
"conversationId": conversation, "priceCent": price, "visitsPerDay": perDay, "cityCode": cityCode,
|
||||
}
|
||||
if confirmDue.Valid {
|
||||
item["confirmDueAt"] = confirmDue.Time.Format(time.RFC3339)
|
||||
@@ -154,7 +176,7 @@ func (a *App) adminFeedTasks(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
reply(w, map[string]any{"items": items})
|
||||
reply(w, map[string]any{"items": items, "total": total})
|
||||
}
|
||||
|
||||
func (a *App) adminFeedDisputes(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -165,6 +187,10 @@ func (a *App) adminFeedDisputes(w http.ResponseWriter, r *http.Request) {
|
||||
where = " WHERE d.status=?"
|
||||
args = append(args, status)
|
||||
}
|
||||
var total int64
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM pet_feed_disputes d
|
||||
JOIN pet_feed_tasks t ON t.id=d.task_id
|
||||
JOIN user_profiles profile ON profile.user_id=d.raised_by`+where, args...).Scan(&total)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT d.id,d.task_id,d.raised_by,profile.nickname,d.reason,d.evidence_json,d.status,d.verdict,
|
||||
d.refund_cent,d.payout_cent,d.handled_note,d.handled_at,d.created_at,t.gross_cent,t.payout_cent,t.status,
|
||||
t.owner_user_id,COALESCE(t.sitter_user_id,0),t.completed_visits,t.total_visits
|
||||
@@ -202,7 +228,7 @@ func (a *App) adminFeedDisputes(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
reply(w, map[string]any{"items": items})
|
||||
reply(w, map[string]any{"items": items, "total": total})
|
||||
}
|
||||
|
||||
// adminHandleFeedDispute is where a human decides who was right. The three
|
||||
|
||||
@@ -47,6 +47,11 @@ func (a *App) adminRefundFeedTask(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, http.StatusNotFound, 30001, "任务不存在")
|
||||
return
|
||||
}
|
||||
// 有订单就以订单那本账为准:任务上那一列只是镜像,靠它算上限,
|
||||
// 就是之前"同一笔钱退两次"的成因。
|
||||
if actual, refundErr := feedRefundedCent(r.Context(), a.db, id); refundErr == nil {
|
||||
refunded = actual
|
||||
}
|
||||
remaining := gross - refunded
|
||||
if remaining <= 0 {
|
||||
fail(w, http.StatusBadRequest, 20001, "这笔任务已经退完了")
|
||||
@@ -63,12 +68,12 @@ func (a *App) adminRefundFeedTask(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 已结算意味着钱在喂养者账上,退给主人的那份只能由平台承担 —— 这是一次
|
||||
// 支出决定,不能顺手做掉,所以要显式勾选。
|
||||
settled := status == "SETTLED" || status == "ARBITRATED"
|
||||
settled := feedStatusSettledOrArbitrated(status)
|
||||
if settled && !req.PlatformBears {
|
||||
fail(w, http.StatusBadRequest, 20001, "任务已结算,报酬在喂养者账上。要退给主人只能由平台承担,请勾选后再提交")
|
||||
return
|
||||
}
|
||||
if status == "CREATED" {
|
||||
if status == feedStatusCreated {
|
||||
fail(w, http.StatusBadRequest, 20001, "任务还没有支付,直接取消即可")
|
||||
return
|
||||
}
|
||||
@@ -101,30 +106,15 @@ func (a *App) adminRefundFeedTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_feed_refunds
|
||||
(task_id,order_id,refund_no,user_id,amount_cent,channel,platform_bears,reason,handled_by)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, id, orderID, refundNo, owner, amount, channel, settled, req.Reason, current(r).ID); err != nil {
|
||||
// 记账只有这一个入口,两条退款路径共用,免得再出现一边记一边不记。
|
||||
if err = recordFeedRefund(r.Context(), tx, feedRefundEntry{
|
||||
TaskID: id, OrderID: orderID, RefundNo: refundNo, UserID: owner,
|
||||
AmountCen: amount, Channel: channel, PlatformBears: settled,
|
||||
Reason: req.Reason, OperatorID: current(r).ID,
|
||||
}); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
|
||||
return
|
||||
}
|
||||
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET refunded_cent=refunded_cent+? WHERE id=?`, amount, id); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
|
||||
return
|
||||
}
|
||||
if orderID.Valid && orderID.Int64 > 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("保存宠物档案失败")
|
||||
|
||||
@@ -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, "举报目标不存在、不可访问或属于当前账号")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user