- 单用户资料接口补上距离:此前只有推荐/附近列表会算距离,资料页和 聊天头部因此无内容可显示。沿用同一套 haversine 与隐私开关。 - 语音/图片消息可限定会员发送,两个开关在管理端「运营配置」中修改 (迁移 032)。校验放在 persistMessageContext,HTTP 与 WebSocket 两条发送路径都覆盖;文本消息永不受限。 - 短信服务关闭时注册不再要求验证码:关掉之后没人能拿到验证码,继续 要求就等于关闭注册通道。重置密码不做同样放宽,那里缺验证码等于 凭手机号夺号。app/config 增加 smsVerification 供客户端决定表单形态。 - 修复 AI 托管账号之间不回复:原规则按「发送方是否托管账号」拦截, 把真人操作测试号的正常对话也挡了。改为标记 worker 自己写入的回复, 只对 AI 生成的消息跳过入队。 - ai.default_model_id 同时接受模型 ID 与名称,填名称时不再被 MySQL 静默转成 0 而使配置失效。 - 聊天媒体留存管理与清理任务(迁移 033,两台线上均已应用)。 新增集成测试均针对真实 MySQL:会员限制、免短信注册、AI 入队规则、 默认模型解析、资料距离。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
353 lines
17 KiB
Go
353 lines
17 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func (a *App) availablePaymentChannels(ctx context.Context) []map[string]any {
|
|
mode := a.configPlain(ctx, "payment.mode", "sandbox")
|
|
gatewayReady := mode == "sandbox" || a.paymentGatewayConfigured(ctx)
|
|
if a.config.Environment == "production" && mode == "sandbox" {
|
|
gatewayReady = false
|
|
}
|
|
channels := []map[string]any{}
|
|
if a.configBool(ctx, "payment.alipay.enabled", true) {
|
|
channels = append(channels, map[string]any{"code": "alipay", "name": "支付宝", "icon": "支", "configured": gatewayReady})
|
|
}
|
|
if a.configBool(ctx, "payment.wechat.enabled", true) {
|
|
channels = append(channels, map[string]any{"code": "wechat", "name": "微信支付", "icon": "微", "configured": gatewayReady})
|
|
}
|
|
return channels
|
|
}
|
|
|
|
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())})
|
|
}
|
|
|
|
type planView struct {
|
|
ID int64 `json:"id"`
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
Level int `json:"level"`
|
|
DurationDays int `json:"durationDays"`
|
|
DailyActiveChatLimit int `json:"dailyActiveChatLimit"`
|
|
DailyLikeLimit int `json:"dailyLikeLimit"`
|
|
CanViewVisitors bool `json:"canViewVisitors"`
|
|
CanInvisibleVisit bool `json:"canInvisibleVisit"`
|
|
RecommendationWeight int `json:"recommendationWeight"`
|
|
PriceCent int `json:"priceCent"`
|
|
OriginalPriceCent int `json:"originalPriceCent"`
|
|
Status int `json:"status"`
|
|
SortOrder int `json:"sortOrder"`
|
|
}
|
|
|
|
func (a *App) membershipPlans(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := a.db.QueryContext(r.Context(), `SELECT id,code,name,level,duration_days,daily_active_chat_limit,daily_like_limit,can_view_visitors,can_invisible_visit,recommendation_weight,price_cent,original_price_cent,status,sort_order FROM membership_plans WHERE status=1 AND deleted_at IS NULL ORDER BY sort_order`)
|
|
if err != nil {
|
|
fail(w, 500, 50001, "查询套餐失败")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
items := []planView{}
|
|
for rows.Next() {
|
|
var item planView
|
|
_ = rows.Scan(&item.ID, &item.Code, &item.Name, &item.Level, &item.DurationDays, &item.DailyActiveChatLimit, &item.DailyLikeLimit, &item.CanViewVisitors, &item.CanInvisibleVisit, &item.RecommendationWeight, &item.PriceCent, &item.OriginalPriceCent, &item.Status, &item.SortOrder)
|
|
items = append(items, item)
|
|
}
|
|
reply(w, map[string]any{"items": items})
|
|
}
|
|
|
|
func (a *App) membershipStatus(w http.ResponseWriter, r *http.Request) {
|
|
quota := a.dailyActiveChatQuota(r.Context(), current(r).ID)
|
|
entitlements := a.resolveMembershipEntitlements(r.Context(), current(r).ID)
|
|
var likeUsed int
|
|
_ = a.db.QueryRowContext(r.Context(), `SELECT used_count FROM user_daily_like_usage WHERE user_id=? AND usage_date=CURRENT_DATE()`, current(r).ID).Scan(&likeUsed)
|
|
likeRemaining := -1
|
|
if entitlements.DailyLikeLimit > 0 {
|
|
likeRemaining = entitlements.DailyLikeLimit - likeUsed
|
|
if likeRemaining < 0 {
|
|
likeRemaining = 0
|
|
}
|
|
}
|
|
// The chat page reads these two to explain the lock before a recording or an
|
|
// upload is made, instead of letting the send fail after the work is done.
|
|
_, voiceGated := a.messageMembershipGate(r.Context(), messageTypeVoice)
|
|
_, imageGated := a.messageMembershipGate(r.Context(), messageTypeImage)
|
|
member := a.hasActiveMembership(r.Context(), current(r).ID)
|
|
entitlementView := map[string]any{"dailyActiveChatLimit": entitlements.DailyActiveChatLimit, "dailyLikeLimit": entitlements.DailyLikeLimit, "dailyLikeUsed": likeUsed, "dailyLikeRemaining": likeRemaining, "canViewVisitors": entitlements.CanViewVisitors, "canInvisibleVisit": entitlements.CanInvisibleVisit, "recommendationWeight": entitlements.RecommendationWeight, "voiceMessageRequiresVip": voiceGated, "imageMessageRequiresVip": imageGated, "canSendVoiceMessage": member || !voiceGated, "canSendImageMessage": member || !imageGated}
|
|
var planName string
|
|
var level int
|
|
var expires time.Time
|
|
err := a.db.QueryRowContext(r.Context(), `SELECT p.name,p.level,s.expires_at FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id WHERE s.user_id=? AND s.status=1 AND s.started_at<=NOW(3) AND s.expires_at>NOW(3) ORDER BY p.level DESC,s.expires_at DESC LIMIT 1`, current(r).ID).Scan(&planName, &level, &expires)
|
|
if err != nil {
|
|
reply(w, map[string]any{"active": false, "dailyActiveChat": quota, "entitlements": entitlementView, "level": 0, "name": "普通用户"})
|
|
return
|
|
}
|
|
reply(w, map[string]any{"active": true, "dailyActiveChat": quota, "entitlements": entitlementView, "level": level, "name": planName, "expiresAt": expires})
|
|
}
|
|
|
|
func (a *App) createOrder(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
PlanID int64 `json:"planId"`
|
|
Channel string `json:"channel"`
|
|
}
|
|
if decode(r, &req) != nil || req.PlanID == 0 {
|
|
fail(w, 400, 20001, "请选择套餐")
|
|
return
|
|
}
|
|
channels := a.availablePaymentChannels(r.Context())
|
|
if req.Channel == "" && len(channels) > 0 {
|
|
req.Channel, _ = channels[0]["code"].(string)
|
|
}
|
|
channelAllowed := false
|
|
for _, channel := range channels {
|
|
if channel["code"] == req.Channel {
|
|
channelAllowed = channel["configured"] == true
|
|
}
|
|
}
|
|
if !channelAllowed {
|
|
fail(w, 400, 20001, "支付渠道未启用或配置不完整")
|
|
return
|
|
}
|
|
var price int
|
|
if a.db.QueryRowContext(r.Context(), `SELECT price_cent FROM membership_plans WHERE id=? AND status=1 AND deleted_at IS NULL`, req.PlanID).Scan(&price) != nil {
|
|
fail(w, 404, 30001, "套餐不存在")
|
|
return
|
|
}
|
|
orderNo := fmt.Sprintf("XY%d%d%s", time.Now().UnixMilli(), current(r).ID, randomToken()[:8])
|
|
result, err := a.db.ExecContext(r.Context(), `INSERT INTO orders(order_no,user_id,product_type,product_id,amount_cent,status,channel)VALUES(?,?,'membership',?,?,'CREATED',?)`, orderNo, current(r).ID, req.PlanID, price, req.Channel)
|
|
if err != nil {
|
|
fail(w, 500, 50001, "创建订单失败")
|
|
return
|
|
}
|
|
id, _ := result.LastInsertId()
|
|
reply(w, map[string]any{"id": id, "orderNo": orderNo, "amountCent": price, "status": "CREATED", "channel": req.Channel})
|
|
}
|
|
|
|
func (a *App) payOrder(w http.ResponseWriter, r *http.Request) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
fail(w, 400, 20001, "订单编号无效")
|
|
return
|
|
}
|
|
mode := a.configPlain(r.Context(), "payment.mode", "sandbox")
|
|
if mode != "sandbox" && mode != "live" {
|
|
fail(w, http.StatusServiceUnavailable, 50003, "支付模式配置无效")
|
|
return
|
|
}
|
|
if mode == "live" {
|
|
var orderNo, status, channel, subject, providerOrderNo, checkoutURL 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)
|
|
if err != nil {
|
|
fail(w, http.StatusNotFound, 30001, "订单不存在")
|
|
return
|
|
}
|
|
if status == "PAID" {
|
|
reply(w, map[string]any{"success": true, "status": "PAID", "mode": mode})
|
|
return
|
|
}
|
|
if status != "CREATED" {
|
|
fail(w, http.StatusBadRequest, 20001, "当前订单状态无法支付")
|
|
return
|
|
}
|
|
if providerOrderNo != "" && (checkoutURL != "" || paymentPayload.Valid) {
|
|
var appPayload map[string]any
|
|
if paymentPayload.Valid && paymentPayload.String != "" {
|
|
_ = json.Unmarshal([]byte(paymentPayload.String), &appPayload)
|
|
}
|
|
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})
|
|
if gatewayErr != nil {
|
|
fail(w, http.StatusBadGateway, 50003, "支付网关下单失败")
|
|
return
|
|
}
|
|
payloadJSON := ""
|
|
if len(gatewayResult.AppPayload) > 0 {
|
|
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 {
|
|
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})
|
|
return
|
|
}
|
|
if a.config.Environment == "production" {
|
|
fail(w, http.StatusServiceUnavailable, 50003, "生产环境禁止沙箱支付")
|
|
return
|
|
}
|
|
tx, err := a.db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
fail(w, 500, 50001, "支付失败")
|
|
return
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
var userID, planID int64
|
|
var amountCent int
|
|
var orderNo string
|
|
var status string
|
|
err = tx.QueryRowContext(r.Context(), `SELECT user_id,product_id,amount_cent,order_no,status FROM orders WHERE id=? AND user_id=? AND deleted_at IS NULL FOR UPDATE`, id, current(r).ID).Scan(&userID, &planID, &amountCent, &orderNo, &status)
|
|
if err != nil {
|
|
fail(w, 404, 30001, "订单不存在")
|
|
return
|
|
}
|
|
if status == "PAID" {
|
|
reply(w, map[string]any{"success": true, "status": "PAID"})
|
|
return
|
|
}
|
|
if status != "CREATED" {
|
|
fail(w, 400, 20001, "当前订单状态无法支付")
|
|
return
|
|
}
|
|
var durationDays, level int
|
|
if err = tx.QueryRowContext(r.Context(), `SELECT duration_days,level FROM membership_plans WHERE id=? AND status=1 AND deleted_at IS NULL`, planID).Scan(&durationDays, &level); err != nil {
|
|
fail(w, 400, 20001, "会员套餐已下架")
|
|
return
|
|
}
|
|
if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='PAID',paid_at=NOW(3),paid_amount_cent=?,provider_order_no=?,payment_notified_at=NOW(3) WHERE id=?`, amountCent, "sandbox:"+orderNo, id); err == nil {
|
|
err = grantOrderMembershipTx(r.Context(), tx, id, userID, planID, durationDays, level)
|
|
}
|
|
if err != nil || tx.Commit() != nil {
|
|
fail(w, 500, 50001, "支付入账失败")
|
|
return
|
|
}
|
|
reply(w, map[string]any{"success": true, "status": "PAID", "mode": mode})
|
|
}
|
|
|
|
func (a *App) orderStatus(w http.ResponseWriter, r *http.Request) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
fail(w, http.StatusBadRequest, 20001, "订单编号无效")
|
|
return
|
|
}
|
|
var orderNo, status, channel, productName, refundReason string
|
|
var amountCent int
|
|
var paidAt, refundRequestedAt sql.NullTime
|
|
if err = a.db.QueryRowContext(r.Context(), `SELECT o.order_no,o.amount_cent,o.status,o.channel,o.paid_at,COALESCE(p.name,'已删除套餐'),o.refund_reason,o.refund_requested_at 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, &paidAt, &productName, &refundReason, &refundRequestedAt); err != nil {
|
|
fail(w, http.StatusNotFound, 30001, "订单不存在")
|
|
return
|
|
}
|
|
result := map[string]any{"id": id, "orderNo": orderNo, "amountCent": amountCent, "status": status, "channel": channel, "productName": productName, "refundReason": refundReason, "refundRequestedAt": nullableTime(refundRequestedAt)}
|
|
if paidAt.Valid {
|
|
result["paidAt"] = paidAt.Time
|
|
}
|
|
reply(w, result)
|
|
}
|
|
|
|
func (a *App) myOrders(w http.ResponseWriter, r *http.Request) {
|
|
page, pageSize, offset := pageOptions(r)
|
|
var total int
|
|
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM orders WHERE user_id=? AND deleted_at IS NULL`, current(r).ID).Scan(&total); err != nil {
|
|
fail(w, 500, 50001, "查询失败")
|
|
return
|
|
}
|
|
rows, err := a.db.QueryContext(r.Context(), `SELECT o.id,o.order_no,o.amount_cent,o.status,o.channel,o.created_at,COALESCE(p.name,'已删除套餐') FROM orders o LEFT JOIN membership_plans p ON p.id=o.product_id WHERE o.user_id=? AND o.deleted_at IS NULL ORDER BY o.created_at DESC LIMIT ? OFFSET ?`, current(r).ID, pageSize, offset)
|
|
if err != nil {
|
|
fail(w, 500, 50001, "查询失败")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
items := []map[string]any{}
|
|
for rows.Next() {
|
|
var id int64
|
|
var orderNo, status, channel, name string
|
|
var amount int
|
|
var created time.Time
|
|
_ = rows.Scan(&id, &orderNo, &amount, &status, &channel, &created, &name)
|
|
items = append(items, map[string]any{"id": id, "orderNo": orderNo, "amountCent": amount, "status": status, "channel": channel, "createdAt": created, "productName": name})
|
|
}
|
|
reply(w, map[string]any{"items": items, "page": page, "pageSize": pageSize, "total": total, "hasMore": offset+len(items) < total})
|
|
}
|
|
|
|
func (a *App) notifications(w http.ResponseWriter, r *http.Request) {
|
|
page, pageSize, offset := pageOptions(r)
|
|
notificationType := strings.TrimSpace(r.URL.Query().Get("type"))
|
|
where := ` WHERE user_id=?`
|
|
args := []any{current(r).ID}
|
|
if notificationType != "" {
|
|
where += ` AND type=?`
|
|
args = append(args, notificationType)
|
|
}
|
|
args = append(args, pageSize, offset)
|
|
rows, err := a.db.QueryContext(r.Context(), `SELECT id,type,title,content,biz_type,biz_id,read_at,created_at FROM notifications`+where+` ORDER BY created_at DESC LIMIT ? OFFSET ?`, args...)
|
|
if err != nil {
|
|
fail(w, 500, 50001, "查询失败")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
items := []map[string]any{}
|
|
for rows.Next() {
|
|
var id int64
|
|
var typ, title, content, bizType string
|
|
var bizID any
|
|
var readAt any
|
|
var created time.Time
|
|
_ = rows.Scan(&id, &typ, &title, &content, &bizType, &bizID, &readAt, &created)
|
|
items = append(items, map[string]any{"id": id, "type": typ, "title": title, "content": content, "bizType": bizType, "bizId": bizID, "readAt": readAt, "createdAt": created})
|
|
}
|
|
var unread, total int
|
|
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM notifications WHERE user_id=? AND read_at IS NULL`, current(r).ID).Scan(&unread)
|
|
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM notifications`+where, args[:len(args)-2]...).Scan(&total)
|
|
rowsByType, _ := a.db.QueryContext(r.Context(), `SELECT type,COUNT(*) FROM notifications WHERE user_id=? AND read_at IS NULL GROUP BY type`, current(r).ID)
|
|
unreadByType := map[string]int{}
|
|
if rowsByType != nil {
|
|
defer rowsByType.Close()
|
|
for rowsByType.Next() {
|
|
var typ string
|
|
var count int
|
|
_ = rowsByType.Scan(&typ, &count)
|
|
unreadByType[typ] = count
|
|
}
|
|
}
|
|
reply(w, map[string]any{"items": items, "page": page, "pageSize": pageSize, "total": total, "unread": unread, "unreadByType": unreadByType, "hasMore": offset+len(items) < total})
|
|
}
|
|
func (a *App) readAllNotifications(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = a.db.ExecContext(r.Context(), `UPDATE notifications SET read_at=NOW(3) WHERE user_id=? AND read_at IS NULL`, current(r).ID)
|
|
reply(w, map[string]bool{"success": true})
|
|
}
|
|
|
|
func (a *App) appConfig(w http.ResponseWriter, r *http.Request) {
|
|
rows, _ := a.db.QueryContext(r.Context(), `SELECT config_key,config_value,value_type FROM system_configs WHERE value_type<>'secret' AND config_key LIKE 'app.%'`)
|
|
configs := map[string]any{}
|
|
if rows != nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var key, value, typ string
|
|
_ = rows.Scan(&key, &value, &typ)
|
|
configs[key] = value
|
|
}
|
|
}
|
|
platform := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("platform")))
|
|
if platform != "android" && platform != "ios" && platform != "h5" {
|
|
platform = "h5"
|
|
}
|
|
latest := map[string]any{}
|
|
var version, downloadURL, notes string
|
|
var build int
|
|
var force bool
|
|
if a.db.QueryRowContext(r.Context(), `SELECT version,build_number,force_update,download_url,release_notes FROM app_versions WHERE platform=? AND status=1 ORDER BY build_number DESC LIMIT 1`, platform).Scan(&version, &build, &force, &downloadURL, ¬es) == nil {
|
|
latest = map[string]any{"version": version, "buildNumber": build, "forceUpdate": force, "downloadUrl": downloadURL, "releaseNotes": notes}
|
|
}
|
|
features := map[string]bool{
|
|
"nearby": a.configBool(r.Context(), "app.features.nearby", true),
|
|
"feed": a.configBool(r.Context(), "app.features.feed", true),
|
|
"membership": a.configBool(r.Context(), "app.features.membership", true),
|
|
"im": a.configBool(r.Context(), "app.features.im", true),
|
|
// The sign-up form hides its code field when this is off, instead of
|
|
// asking for something the server will never send.
|
|
"smsVerification": a.smsVerificationRequired(r.Context()),
|
|
}
|
|
reply(w, map[string]any{"configs": configs, "platform": platform, "features": features, "maintenance": map[string]any{"enabled": a.configBool(r.Context(), "app.maintenance.enabled", false), "message": a.configPlain(r.Context(), "app.maintenance.message", "系统维护中,请稍后再试")}, "legal": map[string]string{"userAgreementVersion": a.configPlain(r.Context(), "legal.user_agreement_version", "1.0"), "privacyPolicyVersion": a.configPlain(r.Context(), "legal.privacy_policy_version", "1.0"), "operatorName": a.configPlain(r.Context(), "legal.operator_name", ""), "contact": a.configPlain(r.Context(), "legal.contact", ""), "effectiveDate": a.configPlain(r.Context(), "legal.effective_date", ""), "userAgreementUrl": a.configPlain(r.Context(), "legal.user_agreement_url", ""), "privacyPolicyUrl": a.configPlain(r.Context(), "legal.privacy_policy_url", "")}, "minVersion": a.configPlain(r.Context(), "app.min_version."+platform, "1.0.0"), "latest": latest})
|
|
}
|