Merge branch 'main' into feature/distribution-system
This commit is contained in:
@@ -72,9 +72,12 @@ func (l *structuredGormLogger) Trace(ctx context.Context, begin time.Time, query
|
|||||||
}
|
}
|
||||||
elapsed := time.Since(begin)
|
elapsed := time.Since(begin)
|
||||||
switch {
|
switch {
|
||||||
case err != nil && !errors.Is(err, gorm.ErrRecordNotFound) && l.level >= gormLogger.Error:
|
case err != nil && !errors.Is(err, gorm.ErrRecordNotFound) && !errors.Is(err, context.Canceled) && l.level >= gormLogger.Error:
|
||||||
sql, rows := query()
|
sql, rows := query()
|
||||||
l.withContext(ctx).Error("数据库查询失败", queryFields(sql, rows, elapsed, err)...)
|
l.withContext(ctx).Error("数据库查询失败", queryFields(sql, rows, elapsed, err)...)
|
||||||
|
case errors.Is(err, context.Canceled) && l.level >= gormLogger.Info:
|
||||||
|
sql, rows := query()
|
||||||
|
l.withContext(ctx).Debug("数据库查询被客户端取消", queryFields(sql, rows, elapsed, err)...)
|
||||||
case elapsed >= l.slowThreshold && l.level >= gormLogger.Warn:
|
case elapsed >= l.slowThreshold && l.level >= gormLogger.Warn:
|
||||||
sql, rows := query()
|
sql, rows := query()
|
||||||
l.withContext(ctx).Warn("数据库慢查询", queryFields(sql, rows, elapsed, nil)...)
|
l.withContext(ctx).Warn("数据库慢查询", queryFields(sql, rows, elapsed, nil)...)
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ func (j *Job) run(ctx context.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
j.logger.Warn("退款重试任务扫描缺失退款单失败", zap.Error(err))
|
j.logger.Warn("退款重试任务扫描缺失退款单失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
if processed > 0 || missing > 0 {
|
if processed > 0 || rebuilt > 0 {
|
||||||
j.logger.Info("退款重试任务处理完成",
|
j.logger.Info("退款重试任务处理完成",
|
||||||
zap.Int("processed", processed),
|
zap.Int("processed", processed),
|
||||||
zap.Int("missing_refund_orders", missing),
|
zap.Int("missing_refund_orders", missing),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -72,6 +74,10 @@ func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
|
case errors.Is(c.Request.Context().Err(), context.Canceled):
|
||||||
|
if logger.Core().Enabled(zap.DebugLevel) {
|
||||||
|
logger.Debug("HTTP 客户端中断连接", fields...)
|
||||||
|
}
|
||||||
case status >= 500:
|
case status >= 500:
|
||||||
logger.Error("HTTP 请求失败", fields...)
|
logger.Error("HTTP 请求失败", fields...)
|
||||||
case status == http.StatusTooManyRequests:
|
case status == http.StatusTooManyRequests:
|
||||||
@@ -113,7 +119,7 @@ func shouldSkipHTTPLog(path, route string, status int, latencyMs float64) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// SSE 的请求耗时就是连接存活时间,由 chathub 单独记录连接生命周期。
|
// SSE 的请求耗时就是连接存活时间,由 chathub 单独记录连接生命周期。
|
||||||
if isChatSSEPath(path, route) {
|
if isSSEPath(path, route) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if latencyMs >= slowRequestThresholdMs {
|
if latencyMs >= slowRequestThresholdMs {
|
||||||
@@ -122,9 +128,10 @@ func shouldSkipHTTPLog(path, route string, status int, latencyMs float64) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func isChatSSEPath(path, route string) bool {
|
func isSSEPath(path, route string) bool {
|
||||||
return path == "/api/chats/events" || path == "/api/admin/chats/events" ||
|
return path == "/api/auth/events" || route == "/api/auth/events" ||
|
||||||
route == "/api/chats/events" || route == "/api/admin/chats/events"
|
path == "/api/chats/events" || route == "/api/chats/events" ||
|
||||||
|
path == "/api/admin/chats/events" || route == "/api/admin/chats/events"
|
||||||
}
|
}
|
||||||
|
|
||||||
func meaningfulAuthFailure(c *gin.Context) bool {
|
func meaningfulAuthFailure(c *gin.Context) bool {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func TestShouldSkipHTTPLog(t *testing.T) {
|
|||||||
{name: "轮询 500 不跳过", path: "/api/wallet/balance", status: 500, latencyMs: 1, wantSkip: false},
|
{name: "轮询 500 不跳过", path: "/api/wallet/balance", status: 500, latencyMs: 1, wantSkip: false},
|
||||||
{name: "限流请求不跳过", path: "/api/auth/sms", status: 429, latencyMs: 1, wantSkip: false},
|
{name: "限流请求不跳过", path: "/api/auth/sms", status: 429, latencyMs: 1, wantSkip: false},
|
||||||
{name: "慢请求不跳过", path: "/api/orders", status: 200, latencyMs: 500, wantSkip: false},
|
{name: "慢请求不跳过", path: "/api/orders", status: 200, latencyMs: 500, wantSkip: false},
|
||||||
|
{name: "用户全局通知 SSE 长连接跳过", path: "/api/auth/events", route: "/api/auth/events", status: 200, latencyMs: 60_000, wantSkip: true},
|
||||||
{name: "用户 SSE 长连接跳过", path: "/api/chats/events", route: "/api/chats/events", status: 200, latencyMs: 60_000, wantSkip: true},
|
{name: "用户 SSE 长连接跳过", path: "/api/chats/events", route: "/api/chats/events", status: 200, latencyMs: 60_000, wantSkip: true},
|
||||||
{name: "后台 SSE 长连接跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 200, latencyMs: 600_000, wantSkip: true},
|
{name: "后台 SSE 长连接跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 200, latencyMs: 600_000, wantSkip: true},
|
||||||
{name: "SSE 服务端错误不跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 500, latencyMs: 600_000, wantSkip: false},
|
{name: "SSE 服务端错误不跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 500, latencyMs: 600_000, wantSkip: false},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package chat
|
package chat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -10,6 +11,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func writeChatError(c *gin.Context, err error) {
|
func writeChatError(c *gin.Context, err error) {
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(c.Request.Context().Err(), context.Canceled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
response.RecordError(c, err)
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const (
|
|||||||
qrCodeOCRJobURLConfigKey = "integration.paddle_ocr_job_url"
|
qrCodeOCRJobURLConfigKey = "integration.paddle_ocr_job_url"
|
||||||
qrCodeOCRModelConfigKey = "integration.paddle_ocr_model"
|
qrCodeOCRModelConfigKey = "integration.paddle_ocr_model"
|
||||||
defaultQrCodeOCRJobURL = "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs"
|
defaultQrCodeOCRJobURL = "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs"
|
||||||
defaultQrCodeOCRModel = "PaddleOCR-VL-1.6"
|
defaultQrCodeOCRModel = "PP-OCRv6"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -443,6 +443,7 @@ func (r *Repository) submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRCo
|
|||||||
optionalPayload, _ := json.Marshal(map[string]bool{
|
optionalPayload, _ := json.Marshal(map[string]bool{
|
||||||
"useDocOrientationClassify": false,
|
"useDocOrientationClassify": false,
|
||||||
"useDocUnwarping": false,
|
"useDocUnwarping": false,
|
||||||
|
"useTextlineOrientation": false,
|
||||||
"useChartRecognition": false,
|
"useChartRecognition": false,
|
||||||
})
|
})
|
||||||
_ = writer.WriteField("optionalPayload", string(optionalPayload))
|
_ = writer.WriteField("optionalPayload", string(optionalPayload))
|
||||||
@@ -790,13 +791,25 @@ func collectPaddleOCRText(value any, texts *[]string) {
|
|||||||
collectPaddleOCRText(item, texts)
|
collectPaddleOCRText(item, texts)
|
||||||
}
|
}
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
|
// PP-OCRv6: prunedResult.rec_texts 包含检测并识别的文字列表
|
||||||
|
if recTexts, ok := v["rec_texts"].([]any); ok {
|
||||||
|
for _, item := range recTexts {
|
||||||
|
if text, ok := item.(string); ok && strings.TrimSpace(text) != "" {
|
||||||
|
*texts = append(*texts, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// PaddleOCR-VL: markdown.text 包含识别文本
|
||||||
if markdown, ok := v["markdown"].(map[string]any); ok {
|
if markdown, ok := v["markdown"].(map[string]any); ok {
|
||||||
if text, ok := markdown["text"].(string); ok && strings.TrimSpace(text) != "" {
|
if text, ok := markdown["text"].(string); ok && strings.TrimSpace(text) != "" {
|
||||||
*texts = append(*texts, text)
|
*texts = append(*texts, text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for key, item := range v {
|
for key, item := range v {
|
||||||
if key == "images" || key == "outputImages" {
|
if key == "images" || key == "outputImages" || key == "ocrImage" || key == "inputImage" ||
|
||||||
|
key == "preprocessedImages" || key == "model_settings" || key == "text_det_params" ||
|
||||||
|
key == "logId" || key == "errorCode" || key == "errorMsg" || key == "dataInfo" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
collectPaddleOCRText(item, texts)
|
collectPaddleOCRText(item, texts)
|
||||||
|
|||||||
@@ -222,3 +222,20 @@ func pngHeader(width, height uint32) []byte {
|
|||||||
binary.Write(&result, binary.BigEndian, checksum)
|
binary.Write(&result, binary.BigEndian, checksum)
|
||||||
return result.Bytes()
|
return result.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCollectPaddleOCRJSONLTextSupportsPPOCRv6(t *testing.T) {
|
||||||
|
rawJSONL := `{"result":{"ocrResults":[{"prunedResult":{"rec_texts":["火爆出租-778群","扫码加入群聊"]},"ocrImage":"https://example.com/ocr.jpg","inputImage":"https://example.com/in.jpg"}]}}`
|
||||||
|
text := collectPaddleOCRJSONLText(rawJSONL)
|
||||||
|
if !strings.Contains(text, "火爆出租-778群") || !strings.Contains(text, "扫码加入群聊") {
|
||||||
|
t.Fatalf("collected text = %q, expected recognized group texts", text)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "ocr.jpg") || strings.Contains(text, "in.jpg") {
|
||||||
|
t.Fatalf("collected text = %q, should not include image URLs", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := parseQrCodeGroupNameCandidates(text)
|
||||||
|
if len(candidates) == 0 || candidates[0] != "火爆出租-778群" {
|
||||||
|
t.Fatalf("candidates = %v, want 火爆出租-778群 as first candidate", candidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ
|
|||||||
var rows []conversationRow
|
var rows []conversationRow
|
||||||
offset := (page - 1) * pageSize
|
offset := (page - 1) * pageSize
|
||||||
queryDB := r.adminConversationBase(ctx, principal, keyword).
|
queryDB := r.adminConversationBase(ctx, principal, keyword).
|
||||||
|
Joins("LEFT JOIN chat_messages AS lm ON lm.id = c.last_message_id").
|
||||||
Select(`c.id, c.order_id, c.listing_id, c.type, c.support_scene, c.title, c.status, c.last_message_id,
|
Select(`c.id, c.order_id, c.listing_id, c.type, c.support_scene, c.title, c.status, c.last_message_id,
|
||||||
c.last_message_preview, c.last_message_at, c.created_at, c.updated_at,
|
c.last_message_preview, c.last_message_at, c.created_at, c.updated_at,
|
||||||
COALESCE(cp_me.role, 'admin') AS role,
|
COALESCE(cp_me.role, 'admin') AS role,
|
||||||
@@ -302,7 +303,6 @@ func (r *Repository) adminConversationBase(ctx context.Context, principal Princi
|
|||||||
db := r.db.WithContext(ctx).Table("chat_conversations AS c").
|
db := r.db.WithContext(ctx).Table("chat_conversations AS c").
|
||||||
Joins("LEFT JOIN chat_participants AS cp_me ON cp_me.conversation_id = c.id AND cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID).
|
Joins("LEFT JOIN chat_participants AS cp_me ON cp_me.conversation_id = c.id AND cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID).
|
||||||
Joins("LEFT JOIN chat_admin_conversation_states AS cas ON cas.conversation_id = c.id AND cas.admin_user_id = ?", principal.ID).
|
Joins("LEFT JOIN chat_admin_conversation_states AS cas ON cas.conversation_id = c.id AND cas.admin_user_id = ?", principal.ID).
|
||||||
Joins("LEFT JOIN chat_messages AS lm ON lm.id = c.last_message_id").
|
|
||||||
Joins("LEFT JOIN rental_orders AS explicit_lo ON explicit_lo.id = c.order_id").
|
Joins("LEFT JOIN rental_orders AS explicit_lo ON explicit_lo.id = c.order_id").
|
||||||
Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(explicit_lo.listing_id, c.listing_id)")
|
Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(explicit_lo.listing_id, c.listing_id)")
|
||||||
if keyword != "" {
|
if keyword != "" {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ var defaultConfigs = []defaultConfig{
|
|||||||
{Key: "chat.renter_retention_days_after_order_end", Value: "5", Description: "订单结束后租客保留在发布群的天数(3-7)"},
|
{Key: "chat.renter_retention_days_after_order_end", Value: "5", Description: "订单结束后租客保留在发布群的天数(3-7)"},
|
||||||
{Key: "integration.paddle_ocr_token", Value: "", Description: "PaddleOCR API Token,用于二维码群名自动识别"},
|
{Key: "integration.paddle_ocr_token", Value: "", Description: "PaddleOCR API Token,用于二维码群名自动识别"},
|
||||||
{Key: "integration.paddle_ocr_job_url", Value: "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs", Description: "PaddleOCR 异步任务接口地址"},
|
{Key: "integration.paddle_ocr_job_url", Value: "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs", Description: "PaddleOCR 异步任务接口地址"},
|
||||||
{Key: "integration.paddle_ocr_model", Value: "PaddleOCR-VL-1.6", Description: "PaddleOCR 识别模型"},
|
{Key: "integration.paddle_ocr_model", Value: "PP-OCRv6", Description: "PaddleOCR 识别模型(默认 PP-OCRv6,支持 PaddleOCR-VL-1.6 等)"},
|
||||||
{Key: homeAnnouncementsConfigKey, Value: defaultHomeAnnouncementsConfigValue(), Description: "移动端首页公告 JSON 数组"},
|
{Key: homeAnnouncementsConfigKey, Value: defaultHomeAnnouncementsConfigValue(), Description: "移动端首页公告 JSON 数组"},
|
||||||
{Key: homeBannersConfigKey, Value: defaultHomeBannersConfigValue(), Description: "移动端首页轮播图 JSON 数组"},
|
{Key: homeBannersConfigKey, Value: defaultHomeBannersConfigValue(), Description: "移动端首页轮播图 JSON 数组"},
|
||||||
{Key: awRecycleConfigKey, Value: defaultAWRecycleConfigValue(), Description: "首页 AW炫彩回收入口配置 JSON(标题/副标题/展示图片)"},
|
{Key: awRecycleConfigKey, Value: defaultAWRecycleConfigValue(), Description: "首页 AW炫彩回收入口配置 JSON(标题/副标题/展示图片)"},
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
UPDATE system_configs
|
||||||
|
SET value = 'PP-OCRv6', description = 'PaddleOCR 识别模型(默认 PP-OCRv6,支持 PaddleOCR-VL-1.6 等)'
|
||||||
|
WHERE `key` = 'integration.paddle_ocr_model' AND (value = 'PaddleOCR-VL-1.6' OR value = '' OR value IS NULL);
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
UPDATE system_configs
|
||||||
|
SET value = 'PaddleOCR-VL-1.6', description = 'PaddleOCR 识别模型'
|
||||||
|
WHERE `key` = 'integration.paddle_ocr_model' AND value = 'PP-OCRv6';
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package response
|
package response
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -45,7 +46,7 @@ func Error(c *gin.Context, status int, code, message string) {
|
|||||||
|
|
||||||
// RecordError 保存原始错误,供统一请求日志记录服务端故障原因。
|
// RecordError 保存原始错误,供统一请求日志记录服务端故障原因。
|
||||||
func RecordError(c *gin.Context, err error) {
|
func RecordError(c *gin.Context, err error) {
|
||||||
if c == nil || err == nil {
|
if c == nil || err == nil || errors.Is(err, context.Canceled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, item := range c.Errors {
|
for _, item := range c.Errors {
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ const paddleOcrJobUrlConfig = computed(() =>
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
const paddleOcrModelConfig = computed(() =>
|
const paddleOcrModelConfig = computed(() =>
|
||||||
systemConfigByKey('integration.paddle_ocr_model', 'PaddleOCR-VL-1.6', 'PaddleOCR 识别模型')
|
systemConfigByKey('integration.paddle_ocr_model', 'PP-OCRv6', 'PaddleOCR 识别模型')
|
||||||
)
|
)
|
||||||
|
|
||||||
const regularConfigs = computed(() =>
|
const regularConfigs = computed(() =>
|
||||||
|
|||||||
@@ -58,7 +58,10 @@ export const systemConfigSelectOptions: Record<string, SystemConfigOption[]> = {
|
|||||||
{ label: '5 天', value: '5' },
|
{ label: '5 天', value: '5' },
|
||||||
{ label: '7 天', value: '7' },
|
{ label: '7 天', value: '7' },
|
||||||
],
|
],
|
||||||
'integration.paddle_ocr_model': [{ label: 'PaddleOCR-VL-1.6', value: 'PaddleOCR-VL-1.6' }],
|
'integration.paddle_ocr_model': [
|
||||||
|
{ label: 'PP-OCRv6(推荐,低延迟少排队)', value: 'PP-OCRv6' },
|
||||||
|
{ label: 'PaddleOCR-VL-1.6', value: 'PaddleOCR-VL-1.6' },
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSystemConfigSelectOptions(key: string) {
|
export function getSystemConfigSelectOptions(key: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user