{"repo": "adachissss/mailnest", "files": {"backend/src/main/resources/application.properties": {"da822d85dd1cf24cf2b9238faaf3389aa93275d6": {"t": 1785745392.8576667, "n": 2210, "h": "0f42aa126e5f45e4", "c": "spring.application.name=MailNestBackend\nserver.address=${MAILNEST_SERVER_ADDRESS:0.0.0.0}\n\nspring.datasource.url=${MAILNEST_DATASOURCE_URL:jdbc:h2:mem:mailnest;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1}\nspring.datasource.username=${MAILNEST_DATASOURCE_USERNAME:sa}\nspring.datasource.password=${MAILNEST_DATASOURCE_PASSWORD:}\nspring.datasource.driver-class-name=${MAILNEST_DATASOURCE_DRIVER:org.h2.Driver}\nspring.sql.init.mode=${MAILNEST_SQL_INIT_MODE:always}\n\nmybatis.configuration.map-underscore-to-camel-case=true\nmybatis.mapper-locations=classpath*:mapper/**/*.xml\n\nmailnest.jwt.issuer=${MAILNEST_JWT_ISSUER:MailNest}\nmailnest.jwt.secret=${MAILNEST_JWT_SECRET:mailnest-demo-secret-change-me-please-32chars}\nmailnest.jwt.expiration=${MAILNEST_JWT_EXPIRATION:PT720H}\nmailnest.secret.encryption-key=${MAILNEST_SECRET_ENCRYPTION_KEY:mailnest-demo-secret-encryption-key-change-me}\n\nmailnest.mail.proxy.enabled=${MAILNEST_MAIL_PROXY_ENABLED:true}\nmailnest.mail.proxy.host=${MAILNEST_MAIL_PROXY_HOST:127.0.0.1}\nmailnest.mail.proxy.port=${MAILNEST_MAIL_PROXY_PORT:7897}\nmailnest.mail.proxy.type=${MAILNEST_MAIL_PROXY_TYPE:HTTP}\n\nmailnest.ai.enabled=${MAILNEST_AI_ENABLED:true}\nmailnest.ai.provider=deepseek\nmailnest.ai.base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com}\nmailnest.ai.api-key=${DEEPSEEK_API_KEY:}\nmailnest.ai.model=${DEEPSEEK_MODEL:deepseek-v4-flash}\nmailnest.ai.temperature=${MAILNEST_AI_TEMPERATURE:0.2}\nmailnest.ai.max-tokens=${MAILNEST_AI_MAX_TOKENS:1200}\nmailnest.ai.timeout=${MAILNEST_AI_TIMEOUT:30s}\nmailnest.ai.max-input-chars=${MAILNEST_AI_MAX_INPUT_CHARS:8000}\n\nspringdoc.api-docs.path=/v3/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\n\nmanagement.endpoints.web.exposure.include=health,info\n\nlogging.level.root=WARN\nlogging.level.com.mailnest=INFO\nlogging.level.com.mailnest.mailaccount=INFO\nlogging.level.com.mailnest.mailfolder=INFO\nlogging.level.com.mailnest.mailidle=INFO\nlogging.level.com.mailnest.mailmessage=INFO\nlogging.level.com.mailnest.mailai=INFO\nlogging.level.org.mybatis=WARN\nlogging.level.org.springframework=WARN\nlogging.level.com.zaxxer.hikari=WARN\nlogging.pattern.console=[%d{HH:mm:ss}] [%-5level] %logger{28} - %msg%n\n"}, "14b9f0880614c9e3a3132d19776ec7a7559bbc7a": {"t": 1785751005.4230835, "n": 2181, "h": "f8f930d27abdaf4e", "c": "spring.application.name=MailNestBackend\nserver.address=${MAILNEST_SERVER_ADDRESS:0.0.0.0}\n\nspring.datasource.url=${MAILNEST_DATASOURCE_URL:jdbc:h2:mem:mailnest;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1}\nspring.datasource.username=${MAILNEST_DATASOURCE_USERNAME:sa}\nspring.datasource.password=${MAILNEST_DATASOURCE_PASSWORD:}\nspring.datasource.driver-class-name=${MAILNEST_DATASOURCE_DRIVER:org.h2.Driver}\nspring.sql.init.mode=${MAILNEST_SQL_INIT_MODE:always}\n\nmybatis.configuration.map-underscore-to-camel-case=true\nmybatis.mapper-locations=classpath*:mapper/**/*.xml\n\nmailnest.jwt.issuer=${MAILNEST_JWT_ISSUER:MailNest}\nmailnest.jwt.secret=${MAILNEST_JWT_SECRET:mailnest-demo-secret-change-me-please-32chars}\nmailnest.jwt.expiration=PT2H\nmailnest.secret.encryption-key=${MAILNEST_SECRET_ENCRYPTION_KEY:mailnest-demo-secret-encryption-key-change-me}\n\nmailnest.mail.proxy.enabled=${MAILNEST_MAIL_PROXY_ENABLED:true}\nmailnest.mail.proxy.host=${MAILNEST_MAIL_PROXY_HOST:127.0.0.1}\nmailnest.mail.proxy.port=${MAILNEST_MAIL_PROXY_PORT:7897}\nmailnest.mail.proxy.type=${MAILNEST_MAIL_PROXY_TYPE:HTTP}\n\nmailnest.ai.enabled=${MAILNEST_AI_ENABLED:true}\nmailnest.ai.provider=deepseek\nmailnest.ai.base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com}\nmailnest.ai.api-key=${DEEPSEEK_API_KEY:}\nmailnest.ai.model=${DEEPSEEK_MODEL:deepseek-v4-flash}\nmailnest.ai.temperature=${MAILNEST_AI_TEMPERATURE:0.2}\nmailnest.ai.max-tokens=${MAILNEST_AI_MAX_TOKENS:1200}\nmailnest.ai.timeout=${MAILNEST_AI_TIMEOUT:30s}\nmailnest.ai.max-input-chars=${MAILNEST_AI_MAX_INPUT_CHARS:8000}\n\nspringdoc.api-docs.path=/v3/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\n\nmanagement.endpoints.web.exposure.include=health,info\n\nlogging.level.root=WARN\nlogging.level.com.mailnest=INFO\nlogging.level.com.mailnest.mailaccount=INFO\nlogging.level.com.mailnest.mailfolder=INFO\nlogging.level.com.mailnest.mailidle=INFO\nlogging.level.com.mailnest.mailmessage=INFO\nlogging.level.com.mailnest.mailai=INFO\nlogging.level.org.mybatis=WARN\nlogging.level.org.springframework=WARN\nlogging.level.com.zaxxer.hikari=WARN\nlogging.pattern.console=[%d{HH:mm:ss}] [%-5level] %logger{28} - %msg%n\n"}, "a38a9a666ca7e8f251970a2a82f7e83d7832828c": {"t": 1785751006.53141, "n": 1937, "h": "da194292dca85ab5", "c": "spring.application.name=MailNestBackend\nserver.address=${MAILNEST_SERVER_ADDRESS:0.0.0.0}\n\nspring.datasource.url=${MAILNEST_DATASOURCE_URL:jdbc:h2:mem:mailnest;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1}\nspring.datasource.username=${MAILNEST_DATASOURCE_USERNAME:sa}\nspring.datasource.password=${MAILNEST_DATASOURCE_PASSWORD:}\nspring.datasource.driver-class-name=${MAILNEST_DATASOURCE_DRIVER:org.h2.Driver}\nspring.sql.init.mode=${MAILNEST_SQL_INIT_MODE:always}\n\nmybatis.configuration.map-underscore-to-camel-case=true\nmybatis.mapper-locations=classpath*:mapper/**/*.xml\n\nmailnest.jwt.issuer=${MAILNEST_JWT_ISSUER:MailNest}\nmailnest.jwt.secret=${MAILNEST_JWT_SECRET:mailnest-demo-secret-change-me-please-32chars}\nmailnest.jwt.expiration=PT2H\nmailnest.secret.encryption-key=${MAILNEST_SECRET_ENCRYPTION_KEY:mailnest-demo-secret-encryption-key-change-me}\n\nmailnest.ai.enabled=${MAILNEST_AI_ENABLED:true}\nmailnest.ai.provider=deepseek\nmailnest.ai.base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com}\nmailnest.ai.api-key=${DEEPSEEK_API_KEY:}\nmailnest.ai.model=${DEEPSEEK_MODEL:deepseek-v4-flash}\nmailnest.ai.temperature=${MAILNEST_AI_TEMPERATURE:0.2}\nmailnest.ai.max-tokens=${MAILNEST_AI_MAX_TOKENS:1200}\nmailnest.ai.timeout=${MAILNEST_AI_TIMEOUT:30s}\nmailnest.ai.max-input-chars=${MAILNEST_AI_MAX_INPUT_CHARS:8000}\n\nspringdoc.api-docs.path=/v3/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\n\nmanagement.endpoints.web.exposure.include=health,info\n\nlogging.level.root=WARN\nlogging.level.com.mailnest=INFO\nlogging.level.com.mailnest.mailaccount=INFO\nlogging.level.com.mailnest.mailfolder=INFO\nlogging.level.com.mailnest.mailidle=INFO\nlogging.level.com.mailnest.mailmessage=INFO\nlogging.level.com.mailnest.mailai=INFO\nlogging.level.org.mybatis=WARN\nlogging.level.org.springframework=WARN\nlogging.level.com.zaxxer.hikari=WARN\nlogging.pattern.console=[%d{HH:mm:ss}] [%-5level] %logger{28} - %msg%n\n"}, "2915b55690605c337a0a3ba3f24aae9b20db18de": {"t": 1785751007.615165, "n": 2020, "h": "44a9ecc0471b4f8f", "c": "spring.application.name=MailNestBackend\n\nspring.datasource.url=${MAILNEST_DATASOURCE_URL:jdbc:h2:mem:mailnest;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1}\nspring.datasource.username=${MAILNEST_DATASOURCE_USERNAME:sa}\nspring.datasource.password=${MAILNEST_DATASOURCE_PASSWORD:}\nspring.datasource.driver-class-name=${MAILNEST_DATASOURCE_DRIVER:org.h2.Driver}\nspring.sql.init.mode=${MAILNEST_SQL_INIT_MODE:always}\n\nmybatis.configuration.map-underscore-to-camel-case=true\nmybatis.mapper-locations=classpath*:mapper/**/*.xml\n\nspring.data.redis.host=${MAILNEST_REDIS_HOST:localhost}\nspring.data.redis.port=${MAILNEST_REDIS_PORT:6379}\n\nmailnest.jwt.issuer=${MAILNEST_JWT_ISSUER:MailNest}\nmailnest.jwt.secret=${MAILNEST_JWT_SECRET:mailnest-demo-secret-change-me-please-32chars}\nmailnest.jwt.expiration=PT2H\nmailnest.secret.encryption-key=${MAILNEST_SECRET_ENCRYPTION_KEY:mailnest-demo-secret-encryption-key-change-me}\n\nmailnest.ai.enabled=${MAILNEST_AI_ENABLED:true}\nmailnest.ai.provider=siliconflow\nmailnest.ai.base-url=${SILICONFLOW_BASE_URL:https://api.siliconflow.cn/v1}\nmailnest.ai.api-key=${SILICONFLOW_API_KEY:}\nmailnest.ai.model=${SILICONFLOW_MODEL:deepseek-ai/DeepSeek-V3.1}\nmailnest.ai.temperature=${MAILNEST_AI_TEMPERATURE:0.2}\nmailnest.ai.max-tokens=${MAILNEST_AI_MAX_TOKENS:1200}\nmailnest.ai.timeout=${MAILNEST_AI_TIMEOUT:30s}\nmailnest.ai.max-input-chars=${MAILNEST_AI_MAX_INPUT_CHARS:8000}\n\nspringdoc.api-docs.path=/v3/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\n\nmanagement.endpoints.web.exposure.include=health,info\n\nlogging.level.root=WARN\nlogging.level.com.mailnest=INFO\nlogging.level.com.mailnest.mailaccount=INFO\nlogging.level.com.mailnest.mailfolder=INFO\nlogging.level.com.mailnest.mailidle=INFO\nlogging.level.com.mailnest.mailmessage=INFO\nlogging.level.com.mailnest.mailai=INFO\nlogging.level.org.mybatis=WARN\nlogging.level.org.springframework=WARN\nlogging.level.com.zaxxer.hikari=WARN\nlogging.pattern.console=[%d{HH:mm:ss}] [%-5level] %logger{28} - %msg%n\n"}, "9c2b406346b05d625a723deaad73b19f046d63b4": {"t": 1785751008.6643732, "n": 1495, "h": "ebe4933c9fb07129", "c": "spring.application.name=MailNestBackend\n\nspring.datasource.url=${MAILNEST_DATASOURCE_URL:jdbc:h2:mem:mailnest;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1}\nspring.datasource.username=${MAILNEST_DATASOURCE_USERNAME:sa}\nspring.datasource.password=${MAILNEST_DATASOURCE_PASSWORD:}\nspring.datasource.driver-class-name=${MAILNEST_DATASOURCE_DRIVER:org.h2.Driver}\nspring.sql.init.mode=${MAILNEST_SQL_INIT_MODE:always}\n\nmybatis.configuration.map-underscore-to-camel-case=true\nmybatis.mapper-locations=classpath*:mapper/**/*.xml\n\nspring.data.redis.host=${MAILNEST_REDIS_HOST:localhost}\nspring.data.redis.port=${MAILNEST_REDIS_PORT:6379}\n\nmailnest.jwt.issuer=${MAILNEST_JWT_ISSUER:MailNest}\nmailnest.jwt.secret=${MAILNEST_JWT_SECRET:mailnest-demo-secret-change-me-please-32chars}\nmailnest.jwt.expiration=PT2H\nmailnest.secret.encryption-key=${MAILNEST_SECRET_ENCRYPTION_KEY:mailnest-demo-secret-encryption-key-change-me}\n\nspringdoc.api-docs.path=/v3/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\n\nmanagement.endpoints.web.exposure.include=health,info\n\nlogging.level.root=WARN\nlogging.level.com.mailnest=INFO\nlogging.level.com.mailnest.mailaccount=INFO\nlogging.level.com.mailnest.mailfolder=INFO\nlogging.level.com.mailnest.mailidle=INFO\nlogging.level.com.mailnest.mailmessage=INFO\nlogging.level.org.mybatis=WARN\nlogging.level.org.springframework=WARN\nlogging.level.com.zaxxer.hikari=WARN\nlogging.pattern.console=[%d{HH:mm:ss}] [%-5level] %logger{28} - %msg%n\n"}, "b1e3538e03888e874f1f02985ed17b26828118e1": {"t": 1785751009.7496867, "n": 1088, "h": "275369fe3871603b", "c": "spring.application.name=MailNestBackend\n\nspring.datasource.url=${MAILNEST_DATASOURCE_URL:jdbc:h2:mem:mailnest;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1}\nspring.datasource.username=${MAILNEST_DATASOURCE_USERNAME:sa}\nspring.datasource.password=${MAILNEST_DATASOURCE_PASSWORD:}\nspring.datasource.driver-class-name=${MAILNEST_DATASOURCE_DRIVER:org.h2.Driver}\nspring.sql.init.mode=${MAILNEST_SQL_INIT_MODE:always}\n\nmybatis.configuration.map-underscore-to-camel-case=true\nmybatis.mapper-locations=classpath*:mapper/**/*.xml\n\nspring.data.redis.host=${MAILNEST_REDIS_HOST:localhost}\nspring.data.redis.port=${MAILNEST_REDIS_PORT:6379}\n\nmailnest.jwt.issuer=${MAILNEST_JWT_ISSUER:MailNest}\nmailnest.jwt.secret=${MAILNEST_JWT_SECRET:mailnest-demo-secret-change-me-please-32chars}\nmailnest.jwt.expiration=PT2H\nmailnest.secret.encryption-key=${MAILNEST_SECRET_ENCRYPTION_KEY:mailnest-demo-secret-encryption-key-change-me}\n\nspringdoc.api-docs.path=/v3/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\n\nmanagement.endpoints.web.exposure.include=health,info\n"}, "132b469de8b8fdf7dc1a6951469d4e58896ef2a9": {"t": 1785751010.8349664, "n": 977, "h": "b9d528a4201bc068", "c": "spring.application.name=MailNestBackend\n\nspring.datasource.url=${MAILNEST_DATASOURCE_URL:jdbc:h2:mem:mailnest;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1}\nspring.datasource.username=${MAILNEST_DATASOURCE_USERNAME:sa}\nspring.datasource.password=${MAILNEST_DATASOURCE_PASSWORD:}\nspring.datasource.driver-class-name=${MAILNEST_DATASOURCE_DRIVER:org.h2.Driver}\nspring.sql.init.mode=${MAILNEST_SQL_INIT_MODE:always}\n\nmybatis.configuration.map-underscore-to-camel-case=true\nmybatis.mapper-locations=classpath*:mapper/**/*.xml\n\nspring.data.redis.host=${MAILNEST_REDIS_HOST:localhost}\nspring.data.redis.port=${MAILNEST_REDIS_PORT:6379}\n\nmailnest.jwt.issuer=${MAILNEST_JWT_ISSUER:MailNest}\nmailnest.jwt.secret=${MAILNEST_JWT_SECRET:mailnest-demo-secret-change-me-please-32chars}\nmailnest.jwt.expiration=PT2H\n\nspringdoc.api-docs.path=/v3/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\n\nmanagement.endpoints.web.exposure.include=health,info\n"}, "411fe961b7c11d39fe3f1e645171ad775c2ec6e0": {"t": 1785751012.3358479, "n": 957, "h": "12e962e4a9d536ae", "c": "spring.application.name=MailNestBackend\n\nspring.datasource.url=${MAILNEST_DATASOURCE_URL:jdbc:mysql://localhost:3306/mailnest?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai}\nspring.datasource.username=${MAILNEST_DATASOURCE_USERNAME:root}\nspring.datasource.password=${MAILNEST_DATASOURCE_PASSWORD:}\nspring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver\nspring.sql.init.mode=${MAILNEST_SQL_INIT_MODE:never}\n\nmybatis.configuration.map-underscore-to-camel-case=true\nmybatis.mapper-locations=classpath*:mapper/**/*.xml\n\nspring.data.redis.host=${MAILNEST_REDIS_HOST:localhost}\nspring.data.redis.port=${MAILNEST_REDIS_PORT:6379}\n\nmailnest.jwt.issuer=${MAILNEST_JWT_ISSUER:MailNest}\nmailnest.jwt.secret=${MAILNEST_JWT_SECRET:mailnest-demo-secret-change-me-please-32chars}\nmailnest.jwt.expiration=PT2H\n\nspringdoc.api-docs.path=/v3/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\n\nmanagement.endpoints.web.exposure.include=health,info\n"}, "e88e669f321e7f3c915a9e7b9ed7eb46d06efdd5": {"t": 1785751013.4181435, "n": 40, "h": "e8ee545bdee13efd", "c": "spring.application.name=MailNestBackend\n"}}, "backend/src/main/java/com/mailnest/sendcenter/ai/SendCenterAiService.java": {"da822d85dd1cf24cf2b9238faaf3389aa93275d6": {"t": 1785745665.3476212, "n": 28495, "h": "fc43476ca4e613ec", "c": "package com.mailnest.sendcenter.ai;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.core.type.TypeReference;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.mailnest.common.ResourceNotFoundException;\nimport com.mailnest.mailaccount.MailAccount;\nimport com.mailnest.mailaccount.MailAccountMapper;\nimport com.mailnest.mailaccount.MailAccountConnectionResolver;\nimport com.mailnest.mailai.AiChatClient;\nimport com.mailnest.mailai.MailAiException;\nimport com.mailnest.mailai.MailAiProperties;\nimport com.mailnest.mailmessage.dto.MailBatchSendItemResponse;\nimport com.mailnest.mailmessage.dto.MailBatchSendResponse;\nimport com.mailnest.mailprotocol.MailSendCommand;\nimport com.mailnest.mailprotocol.MailSendResult;\nimport com.mailnest.mailprotocol.MailSender;\nimport com.mailnest.recipient.RecipientGroup;\nimport com.mailnest.security.CurrentUser;\nimport com.mailnest.security.secret.SecretCryptoService;\nimport com.mailnest.settings.UserSettingsService;\nimport com.mailnest.sendcenter.ai.SendCenterRecipientResolver.ResolvedRecipient;\nimport com.mailnest.sendcenter.ai.SendCenterRecipientResolver.ResolvedSendCenterRecipients;\nimport com.mailnest.sendcenter.ai.dto.AiBatchDraftItem;\nimport com.mailnest.sendcenter.ai.dto.AiBatchDraftResponse;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiBatchResponse;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiGenerateRequest;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiItemResponse;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiRegenerateRequest;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiUpdateItemRequest;\nimport com.mailnest.sendcenter.ai.dto.SendCenterPersonalizedSendRequest;\nimport com.mailnest.sendhistory.SendHistoryService;\nimport com.mailnest.sendhistory.SendHistoryService.SendHistoryItemCreate;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.LocalDateTime;\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\n\n@Service\npublic class SendCenterAiService {\n private static final String SYSTEM_PROMPT = \"\"\"\n 你是 MailNest 的发信中心 AI 群发助手,负责为用户选定的每个收件人生成个性化中文邮件草稿。\n 你必须只输出合法 JSON,不要输出 Markdown,不要添加解释。\n 不要编造事实、日期、附件、承诺、历史关系或用户身份;信息不足时使用稳妥表达。\n 每个收件人必须只生成给该收件人的一封邮件,不能泄露其他收件人或群发名单。\n \"\"\";\n\n private static final TypeReference> LONG_LIST_TYPE = new TypeReference<>() {\n };\n private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {\n };\n\n private final SendCenterAiBatchMapper mapper;\n private final SendCenterRecipientResolver recipientResolver;\n private final MailAccountMapper mailAccountMapper;\n private final SecretCryptoService secretCryptoService;\n private final MailSender mailSender;\n private final SendHistoryService sendHistoryService;\n private final AiChatClient aiChatClient;\n private final MailAiProperties properties;\n private final UserSettingsService userSettingsService;\n private final MailAccountConnectionResolver connectionResolver;\n private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();\n\n public SendCenterAiService(SendCenterAiBatchMapper mapper,\n SendCenterRecipientResolver recipientResolver,\n MailAccountMapper mailAccountMapper,\n SecretCryptoService secretCryptoService,\n MailSender mailSender,\n SendHistoryService sendHistoryService,\n AiChatClient aiChatClient,\n MailAiProperties properties,\n UserSettingsService userSettingsService,\n MailAccountConnectionResolver connectionResolver) {\n this.mapper = mapper;\n this.recipientResolver = recipientResolver;\n this.mailAccountMapper = mailAccountMapper;\n this.secretCryptoService = secretCryptoService;\n this.mailSender = mailSender;\n this.sendHistoryService = sendHistoryService;\n this.aiChatClient = aiChatClient;\n this.properties = properties;\n this.userSettingsService = userSettingsService;\n this.connectionResolver = connectionResolver;\n }\n\n @Transactional\n public SendCenterAiBatchResponse generate(CurrentUser currentUser, SendCenterAiGenerateRequest request) {\n String apiKeyOverride = resolvePersonalAiApiKey(currentUser);\n ensureAiConfigured(apiKeyOverride);\n mailAccountMapper.findByIdAndUserId(request.accountId(), currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"邮箱账号不存在\"));\n ResolvedSendCenterRecipients resolved = recipientResolver.resolve(\n currentUser.id(), request.profileIds(), request.groupIds(), request.manualRecipients());\n if (resolved.recipients().isEmpty()) {\n throw new IllegalArgumentException(\"请至少选择一个收件人\");\n }\n if (safe(request.userInstruction()).isBlank() && safe(request.baseSubject()).isBlank() && safe(request.baseTextBody()).isBlank()) {\n throw new IllegalArgumentException(\"请提供群发写作要求或基础草稿\");\n }\n\n SendCenterAiBatch batch = buildBatch(currentUser.id(), request, resolved.recipients().size());\n mapper.insertBatch(batch);\n Map itemByEmail = insertPendingItems(batch.getId(), resolved.recipients());\n try {\n AiBatchDraftResponse aiResponse = readAiBatchDraftResponse(\n aiChatClient.completeJson(SYSTEM_PROMPT, buildBatchPrompt(request, resolved), apiKeyOverride)\n );\n applyGeneratedItems(batch, itemByEmail, aiResponse.items());\n } catch (JsonProcessingException ex) {\n markAllFailed(batch, itemByEmail, \"AI 返回格式异常\");\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n }\n return get(currentUser, batch.getId());\n }\n\n public SendCenterAiBatchResponse get(CurrentUser currentUser, Long batchId) {\n SendCenterAiBatch batch = getOwnedBatch(currentUser, batchId);\n return toBatchResponse(batch, mapper.findItemsByBatchId(batch.getId()));\n }\n\n @Transactional\n public SendCenterAiItemResponse updateItem(CurrentUser currentUser, Long batchId, Long itemId, SendCenterAiUpdateItemRequest request) {\n getOwnedBatch(currentUser, batchId);\n SendCenterAiItem item = getOwnedItem(currentUser, batchId, itemId);\n item.setSubject(request.subject());\n item.setTextBody(request.textBody());\n item.setHtmlBody(request.htmlBody());\n item.setStatus(\"GENERATED\");\n item.setErrorMessage(null);\n item.setGeneratedAt(LocalDateTime.now());\n mapper.updateItem(item);\n refreshBatchStatus(batchId, currentUser.id());\n return toItemResponse(getOwnedItem(currentUser, batchId, itemId));\n }\n\n @Transactional\n public SendCenterAiItemResponse regenerateItem(CurrentUser currentUser, Long batchId, Long itemId, SendCenterAiRegenerateRequest request) {\n String apiKeyOverride = resolvePersonalAiApiKey(currentUser);\n ensureAiConfigured(apiKeyOverride);\n SendCenterAiBatch batch = getOwnedBatch(currentUser, batchId);\n SendCenterAiItem item = getOwnedItem(currentUser, batchId, itemId);\n try {\n AiBatchDraftResponse response = readAiBatchDraftResponse(\n aiChatClient.completeJson(SYSTEM_PROMPT, buildRegeneratePrompt(batch, item, request), apiKeyOverride)\n );\n AiBatchDraftItem generated = response.items() == null || response.items().isEmpty() ? null : response.items().get(0);\n if (generated == null) {\n applyFailure(item, request.feedback(), \"AI 未返回该联系人草稿\");\n } else if (safe(generated.subject()).isBlank() || safe(generated.textBody()).isBlank()) {\n applyFailure(item, request.feedback(), safe(generated.errorMessage()).isBlank() ? \"AI 返回草稿缺少主题或正文\" : generated.errorMessage());\n } else {\n applyGenerated(item, generated, request.feedback());\n }\n mapper.updateItem(item);\n refreshBatchStatus(batchId, currentUser.id());\n return toItemResponse(getOwnedItem(currentUser, batchId, itemId));\n } catch (JsonProcessingException ex) {\n applyFailure(item, request.feedback(), \"AI 返回格式异常\");\n mapper.updateItem(item);\n refreshBatchStatus(batchId, currentUser.id());\n return toItemResponse(getOwnedItem(currentUser, batchId, itemId));\n }\n }\n\n @Transactional\n public MailBatchSendResponse sendPersonalized(CurrentUser currentUser, Long batchId, SendCenterPersonalizedSendRequest request) {\n SendCenterAiBatch batch = getOwnedBatch(currentUser, batchId);\n MailAccount account = mailAccountMapper.findByIdAndUserId(batch.getAccountId(), currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"邮箱账号不存在\"));\n String authSecret = resolveSendAuthSecret(account);\n List selectedItemIds = distinctIds(request == null ? null : request.itemIds());\n List sendItems = mapper.findItemsByBatchId(batchId).stream()\n .filter(item -> selectedItemIds.isEmpty() || selectedItemIds.contains(item.getId()))\n .filter(item -> \"GENERATED\".equals(item.getStatus()))\n .filter(item -> !safe(item.getSubject()).isBlank() && !safe(item.getTextBody()).isBlank())\n .toList();\n if (sendItems.isEmpty()) {\n throw new IllegalArgumentException(\"没有可发送的 AI 个性化草稿\");\n }\n\n List responses = new ArrayList<>();\n List historyItems = new ArrayList<>();\n for (SendCenterAiItem item : sendItems) {\n try {\n MailSendResult result = mailSender.send(toSendCommand(account, authSecret, item));\n responses.add(new MailBatchSendItemResponse(item.getProfileId(), item.getDisplayName(), item.getEmailAddress(), item.getSource(), true, null, result.messageId(), result.sentAt()));\n historyItems.add(new SendHistoryItemCreate(\n item.getProfileId(), item.getDisplayName(), item.getEmailAddress(), item.getSource(),\n item.getSubject(), item.getTextBody(), item.getHtmlBody(), true, null, result.messageId(), result.sentAt()));\n } catch (RuntimeException ex) {\n responses.add(new MailBatchSendItemResponse(item.getProfileId(), item.getDisplayName(), item.getEmailAddress(), item.getSource(), false, ex.getMessage(), null, null));\n historyItems.add(new SendHistoryItemCreate(\n item.getProfileId(), item.getDisplayName(), item.getEmailAddress(), item.getSource(),\n null, null, null, false, ex.getMessage(), null, null));\n }\n }\n int successCount = (int) responses.stream().filter(MailBatchSendItemResponse::success).count();\n sendHistoryService.recordPersonalizedBatch(currentUser.id(), batch.getAccountId(), account.getEmailAddress(), \"AI 个性化群发\", historyItems);\n return new MailBatchSendResponse(responses.size(), successCount, responses.size() - successCount, responses);\n }\n\n private SendCenterAiBatch buildBatch(Long userId, SendCenterAiGenerateRequest request, int totalCount) {\n SendCenterAiBatch batch = new SendCenterAiBatch();\n batch.setUserId(userId);\n batch.setAccountId(request.accountId());\n batch.setProfileIds(writeJson(distinctIds(request.profileIds())));\n batch.setGroupIds(writeJson(distinctIds(request.groupIds())));\n batch.setManualRecipients(writeJson(normalizeManualRecipients(request.manualRecipients())));\n batch.setUserInstruction(request.userInstruction());\n batch.setBaseSubject(request.baseSubject());\n batch.setBaseTextBody(request.baseTextBody());\n batch.setModel(properties.getModel());\n batch.setStatus(\"GENERATING\");\n batch.setTotalCount(totalCount);\n batch.setGeneratedCount(0);\n batch.setFailedCount(0);\n return batch;\n }\n\n private Map insertPendingItems(Long batchId, List recipients) {\n Map itemByEmail = new LinkedHashMap<>();\n for (ResolvedRecipient recipient : recipients) {\n SendCenterAiItem item = new SendCenterAiItem();\n item.setBatchId(batchId);\n item.setProfileId(recipient.profileId());\n item.setDisplayName(recipient.displayName());\n item.setEmailAddress(recipient.emailAddress());\n item.setSource(recipient.source());\n item.setStatus(\"PENDING\");\n mapper.insertItem(item);\n itemByEmail.put(recipient.emailAddress(), item);\n }\n return itemByEmail;\n }\n\n private void applyGeneratedItems(SendCenterAiBatch batch, Map itemByEmail, List generatedItems) {\n Map generatedByEmail = new LinkedHashMap<>();\n for (AiBatchDraftItem generated : generatedItems == null ? List.of() : generatedItems) {\n String email = normalizeEmail(generated.emailAddress());\n if (!email.isBlank() && itemByEmail.containsKey(email)) {\n generatedByEmail.put(email, generated);\n }\n }\n for (Map.Entry entry : itemByEmail.entrySet()) {\n AiBatchDraftItem generated = generatedByEmail.get(entry.getKey());\n SendCenterAiItem item = entry.getValue();\n if (generated == null) {\n applyFailure(item, null, \"AI 未返回该联系人草稿\");\n } else if (safe(generated.subject()).isBlank() || safe(generated.textBody()).isBlank()) {\n applyFailure(item, null, safe(generated.errorMessage()).isBlank() ? \"AI 返回草稿缺少主题或正文\" : generated.errorMessage());\n } else {\n applyGenerated(item, generated, null);\n }\n mapper.updateItem(item);\n }\n refreshBatchStatus(batch.getId(), batch.getUserId());\n }\n\n private void markAllFailed(SendCenterAiBatch batch, Map itemByEmail, String errorMessage) {\n for (SendCenterAiItem item : itemByEmail.values()) {\n applyFailure(item, null, errorMessage);\n mapper.updateItem(item);\n }\n refreshBatchStatus(batch.getId(), batch.getUserId());\n }\n\n private void applyGenerated(SendCenterAiItem item, AiBatchDraftItem generated, String feedback) {\n item.setSubject(generated.subject());\n item.setTextBody(generated.textBody());\n item.setHtmlBody(generated.htmlBody());\n item.setStatus(\"GENERATED\");\n item.setSafetyLevel(safe(generated.safetyLevel()).isBlank() ? \"LOW\" : generated.safetyLevel());\n item.setSummary(generated.summary());\n item.setImprovementsJson(writeJson(generated.improvements() == null ? List.of() : generated.improvements()));\n item.setRisksJson(writeJson(generated.risks() == null ? List.of() : generated.risks()));\n item.setLastFeedback(feedback);\n item.setErrorMessage(null);\n item.setGeneratedAt(LocalDateTime.now());\n }\n\n private void applyFailure(SendCenterAiItem item, String feedback, String errorMessage) {\n item.setStatus(\"FAILED\");\n item.setLastFeedback(feedback);\n item.setErrorMessage(errorMessage);\n item.setGeneratedAt(LocalDateTime.now());\n }\n\n private void refreshBatchStatus(Long batchId, Long userId) {\n SendCenterAiBatch batch = mapper.findBatchByIdAndUserId(batchId, userId)\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 群发批次不存在\"));\n List items = mapper.findItemsByBatchId(batchId);\n int generatedCount = (int) items.stream().filter(item -> \"GENERATED\".equals(item.getStatus())).count();\n int failedCount = (int) items.stream().filter(item -> \"FAILED\".equals(item.getStatus())).count();\n batch.setGeneratedCount(generatedCount);\n batch.setFailedCount(failedCount);\n if (generatedCount == items.size()) {\n batch.setStatus(\"GENERATED\");\n } else if (generatedCount > 0) {\n batch.setStatus(\"PARTIAL_FAILED\");\n } else if (failedCount == items.size()) {\n batch.setStatus(\"FAILED\");\n } else {\n batch.setStatus(\"GENERATING\");\n }\n mapper.updateBatchStatus(batch);\n }\n\n private String buildBatchPrompt(SendCenterAiGenerateRequest request, ResolvedSendCenterRecipients resolved) throws JsonProcessingException {\n return truncatePrompt(\"\"\"\n 请为下面去重后的联系人分别生成个性化邮件,并严格输出 JSON:\n {\n \"items\": [\n {\n \"emailAddress\": \"必须原样返回联系人邮箱\",\n \"subject\": \"邮件主题\",\n \"textBody\": \"纯文本正文\",\n \"htmlBody\": null,\n \"safetyLevel\": \"LOW | MEDIUM | HIGH\",\n \"summary\": \"一句话说明生成策略\",\n \"improvements\": [\"相对基础稿的改进点\"],\n \"risks\": [\"发送前注意点\"],\n \"errorMessage\": null\n }\n ]\n }\n\n 群发写作要求:%s\n 基础主题:%s\n 基础正文:%s\n\n 群组上下文:\n %s\n\n 联系人列表:\n %s\n \"\"\".formatted(\n blankAsNone(request.userInstruction()),\n blankAsNone(request.baseSubject()),\n blankAsNone(request.baseTextBody()),\n objectMapper.writeValueAsString(resolved.groups().stream().map(this::groupContext).toList()),\n objectMapper.writeValueAsString(resolved.recipients().stream().map(this::recipientContext).toList())\n ));\n }\n\n private String buildRegeneratePrompt(SendCenterAiBatch batch, SendCenterAiItem item, SendCenterAiRegenerateRequest request) throws JsonProcessingException {\n return truncatePrompt(\"\"\"\n 请根据用户反馈,只为这一个联系人重新生成一封个性化邮件。\n 必须只输出下面这个 JSON 对象,不要输出 Markdown、代码块、解释文本或额外字段;items 必须只包含一个元素。\n 如果反馈要求无法满足,也必须返回合法 JSON,并在 errorMessage 中说明原因。\n\n {\n \"items\": [\n {\n \"emailAddress\": \"%s\",\n \"subject\": \"邮件主题\",\n \"textBody\": \"纯文本正文\",\n \"htmlBody\": null,\n \"safetyLevel\": \"LOW\",\n \"summary\": \"一句话说明如何根据反馈调整\",\n \"improvements\": [\"相对当前草稿的改进点\"],\n \"risks\": [],\n \"errorMessage\": null\n }\n ]\n }\n\n 原始群发写作要求:%s\n 基础主题:%s\n 基础正文:%s\n 联系人:%s\n 当前主题:%s\n 当前正文:%s\n 用户反馈:%s\n \"\"\".formatted(\n item.getEmailAddress(),\n blankAsNone(batch.getUserInstruction()),\n blankAsNone(batch.getBaseSubject()),\n blankAsNone(batch.getBaseTextBody()),\n objectMapper.writeValueAsString(Map.of(\n \"emailAddress\", item.getEmailAddress(),\n \"displayName\", safe(item.getDisplayName()),\n \"source\", safe(item.getSource())\n )),\n blankAsNone(request.currentSubject() == null ? item.getSubject() : request.currentSubject()),\n blankAsNone(request.currentTextBody() == null ? item.getTextBody() : request.currentTextBody()),\n request.feedback()\n ));\n }\n\n private Map recipientContext(ResolvedRecipient recipient) {\n Map context = new LinkedHashMap<>();\n context.put(\"emailAddress\", recipient.emailAddress());\n context.put(\"displayName\", safe(recipient.displayName()));\n context.put(\"source\", recipient.source());\n context.put(\"role\", safe(recipient.role()));\n context.put(\"relationship\", safe(recipient.relationship()));\n context.put(\"personality\", safe(recipient.personality()));\n context.put(\"communicationPreference\", safe(recipient.communicationPreference()));\n context.put(\"notes\", safe(recipient.notes()));\n if (recipient.group() != null) {\n context.put(\"group\", groupContext(recipient.group()));\n }\n return context;\n }\n\n private Map groupContext(RecipientGroup group) {\n Map context = new LinkedHashMap<>();\n context.put(\"name\", group.getName());\n context.put(\"description\", safe(group.getDescription()));\n context.put(\"scenario\", safe(group.getScenario()));\n context.put(\"tonePreference\", safe(group.getTonePreference()));\n context.put(\"bulkSendGuidance\", safe(group.getBulkSendGuidance()));\n return context;\n }\n\n private MailSendCommand toSendCommand(MailAccount account, String authSecret, SendCenterAiItem item) {\n return new MailSendCommand(\n account.getProvider(),\n account.getSmtpHost(),\n account.getSmtpPort(),\n account.getSmtpSsl(),\n account.getSmtpStartTls(),\n account.getAuthUsername(),\n authSecret,\n connectionResolver.proxyConfig(account),\n account.getEmailAddress(),\n item.getEmailAddress(),\n item.getSubject(),\n item.getTextBody(),\n item.getHtmlBody()\n );\n }\n\n private SendCenterAiBatch getOwnedBatch(CurrentUser currentUser, Long batchId) {\n return mapper.findBatchByIdAndUserId(batchId, currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 群发批次不存在\"));\n }\n\n private SendCenterAiItem getOwnedItem(CurrentUser currentUser, Long batchId, Long itemId) {\n return mapper.findItemByIdAndBatchIdAndUserId(itemId, batchId, currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 群发联系人草稿不存在\"));\n }\n\n private SendCenterAiBatchResponse toBatchResponse(SendCenterAiBatch batch, List items) {\n return new SendCenterAiBatchResponse(\n batch.getId(),\n batch.getAccountId(),\n readLongList(batch.getProfileIds()),\n readLongList(batch.getGroupIds()),\n readStringList(batch.getManualRecipients()),\n batch.getUserInstruction(),\n batch.getBaseSubject(),\n batch.getBaseTextBody(),\n batch.getModel(),\n batch.getStatus(),\n batch.getTotalCount(),\n batch.getGeneratedCount(),\n batch.getFailedCount(),\n batch.getCreatedAt(),\n batch.getUpdatedAt(),\n items.stream().map(this::toItemResponse).toList()\n );\n }\n\n private SendCenterAiItemResponse toItemResponse(SendCenterAiItem item) {\n return new SendCenterAiItemResponse(\n item.getId(),\n item.getProfileId(),\n item.getDisplayName(),\n item.getEmailAddress(),\n item.getSource(),\n item.getSubject(),\n item.getTextBody(),\n item.getHtmlBody(),\n item.getStatus(),\n item.getSafetyLevel(),\n item.getSummary(),\n readStringList(item.getImprovementsJson()),\n readStringList(item.getRisksJson()),\n item.getLastFeedback(),\n item.getErrorMessage(),\n item.getGeneratedAt(),\n item.getCreatedAt(),\n item.getUpdatedAt()\n );\n }\n\n private void ensureAiConfigured(String apiKeyOverride) {\n if (!properties.isEnabled()) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未启用\");\n }\n if ((apiKeyOverride == null || apiKeyOverride.isBlank()) && (properties.getApiKey() == null || properties.getApiKey().isBlank())) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未配置,请先设置 DEEPSEEK_API_KEY 或个人 AI API Key\");\n }\n }\n\n private String resolvePersonalAiApiKey(CurrentUser currentUser) {\n return userSettingsService.resolveAiApiKey(currentUser.id());\n }\n\n private String resolveSendAuthSecret(MailAccount account) {\n if (!Boolean.TRUE.equals(account.getEnabled())) {\n throw new IllegalArgumentException(\"邮箱账号已禁用,无法发送邮件\");\n }\n String authSecret = secretCryptoService.decrypt(account.getAuthSecretEncrypted());\n if (account.getAuthUsername() == null || account.getAuthUsername().isBlank() || authSecret == null || authSecret.isBlank()) {\n throw new IllegalArgumentException(\"发送邮件需要邮箱账号和授权码\");\n }\n return authSecret;\n }\n\n private List distinctIds(List ids) {\n if (ids == null || ids.isEmpty()) return List.of();\n return ids.stream().filter(id -> id != null)\n .collect(LinkedHashSet::new, LinkedHashSet::add, LinkedHashSet::addAll)\n .stream().toList();\n }\n\n private List normalizeManualRecipients(List recipients) {\n if (recipients == null || recipients.isEmpty()) return List.of();\n return recipients.stream().map(this::normalizeEmail).filter(email -> !email.isBlank())\n .collect(LinkedHashSet::new, LinkedHashSet::add, LinkedHashSet::addAll)\n .stream().toList();\n }\n\n private String normalizeEmail(String email) {\n return email == null ? \"\" : email.trim().toLowerCase(Locale.ROOT);\n }\n\n private String truncatePrompt(String prompt) {\n int maxChars = Math.max(1000, properties.getMaxInputChars());\n return prompt.length() <= maxChars ? prompt : prompt.substring(0, maxChars);\n }\n\n private AiBatchDraftResponse readAiBatchDraftResponse(String content) throws JsonProcessingException {\n return objectMapper.readValue(extractJsonObject(content), AiBatchDraftResponse.class);\n }\n\n private String extractJsonObject(String content) {\n String text = safe(content).trim();\n if (text.startsWith(\"```\")) {\n int firstLineEnd = text.indexOf('\\n');\n int fenceEnd = text.lastIndexOf(\"```\");\n if (firstLineEnd >= 0 && fenceEnd > firstLineEnd) {\n text = text.substring(firstLineEnd + 1, fenceEnd).trim();\n }\n }\n int start = text.indexOf('{');\n int end = text.lastIndexOf('}');\n if (start >= 0 && end > start) {\n return text.substring(start, end + 1);\n }\n return text;\n }\n\n private String blankAsNone(String value) {\n return safe(value).isBlank() ? \"无\" : value;\n }\n\n private String safe(Object value) {\n return value == null ? \"\" : value.toString();\n }\n\n private String writeJson(Object value) {\n try {\n return objectMapper.writeValueAsString(value);\n } catch (JsonProcessingException ex) {\n throw new IllegalStateException(\"AI 群发数据序列化失败\", ex);\n }\n }\n\n private List readLongList(String json) {\n if (json == null || json.isBlank()) return List.of();\n try {\n return objectMapper.readValue(json, LONG_LIST_TYPE);\n } catch (JsonProcessingException ex) {\n throw new IllegalStateException(\"AI 群发批次数据解析失败\", ex);\n }\n }\n\n private List readStringList(String json) {\n if (json == null || json.isBlank()) return List.of();\n try {\n return objectMapper.readValue(json, STRING_LIST_TYPE);\n } catch (JsonProcessingException ex) {\n throw new IllegalStateException(\"AI 群发条目数据解析失败\", ex);\n }\n }\n}\n"}, "5cdbcfaa2f131dbe947101e90bd606d3159d7775": {"t": 1785751006.3495035, "n": 28495, "h": "fc43476ca4e613ec", "c": "package com.mailnest.sendcenter.ai;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.core.type.TypeReference;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.mailnest.common.ResourceNotFoundException;\nimport com.mailnest.mailaccount.MailAccount;\nimport com.mailnest.mailaccount.MailAccountMapper;\nimport com.mailnest.mailaccount.MailAccountConnectionResolver;\nimport com.mailnest.mailai.AiChatClient;\nimport com.mailnest.mailai.MailAiException;\nimport com.mailnest.mailai.MailAiProperties;\nimport com.mailnest.mailmessage.dto.MailBatchSendItemResponse;\nimport com.mailnest.mailmessage.dto.MailBatchSendResponse;\nimport com.mailnest.mailprotocol.MailSendCommand;\nimport com.mailnest.mailprotocol.MailSendResult;\nimport com.mailnest.mailprotocol.MailSender;\nimport com.mailnest.recipient.RecipientGroup;\nimport com.mailnest.security.CurrentUser;\nimport com.mailnest.security.secret.SecretCryptoService;\nimport com.mailnest.settings.UserSettingsService;\nimport com.mailnest.sendcenter.ai.SendCenterRecipientResolver.ResolvedRecipient;\nimport com.mailnest.sendcenter.ai.SendCenterRecipientResolver.ResolvedSendCenterRecipients;\nimport com.mailnest.sendcenter.ai.dto.AiBatchDraftItem;\nimport com.mailnest.sendcenter.ai.dto.AiBatchDraftResponse;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiBatchResponse;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiGenerateRequest;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiItemResponse;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiRegenerateRequest;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiUpdateItemRequest;\nimport com.mailnest.sendcenter.ai.dto.SendCenterPersonalizedSendRequest;\nimport com.mailnest.sendhistory.SendHistoryService;\nimport com.mailnest.sendhistory.SendHistoryService.SendHistoryItemCreate;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.LocalDateTime;\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\n\n@Service\npublic class SendCenterAiService {\n private static final String SYSTEM_PROMPT = \"\"\"\n 你是 MailNest 的发信中心 AI 群发助手,负责为用户选定的每个收件人生成个性化中文邮件草稿。\n 你必须只输出合法 JSON,不要输出 Markdown,不要添加解释。\n 不要编造事实、日期、附件、承诺、历史关系或用户身份;信息不足时使用稳妥表达。\n 每个收件人必须只生成给该收件人的一封邮件,不能泄露其他收件人或群发名单。\n \"\"\";\n\n private static final TypeReference> LONG_LIST_TYPE = new TypeReference<>() {\n };\n private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {\n };\n\n private final SendCenterAiBatchMapper mapper;\n private final SendCenterRecipientResolver recipientResolver;\n private final MailAccountMapper mailAccountMapper;\n private final SecretCryptoService secretCryptoService;\n private final MailSender mailSender;\n private final SendHistoryService sendHistoryService;\n private final AiChatClient aiChatClient;\n private final MailAiProperties properties;\n private final UserSettingsService userSettingsService;\n private final MailAccountConnectionResolver connectionResolver;\n private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();\n\n public SendCenterAiService(SendCenterAiBatchMapper mapper,\n SendCenterRecipientResolver recipientResolver,\n MailAccountMapper mailAccountMapper,\n SecretCryptoService secretCryptoService,\n MailSender mailSender,\n SendHistoryService sendHistoryService,\n AiChatClient aiChatClient,\n MailAiProperties properties,\n UserSettingsService userSettingsService,\n MailAccountConnectionResolver connectionResolver) {\n this.mapper = mapper;\n this.recipientResolver = recipientResolver;\n this.mailAccountMapper = mailAccountMapper;\n this.secretCryptoService = secretCryptoService;\n this.mailSender = mailSender;\n this.sendHistoryService = sendHistoryService;\n this.aiChatClient = aiChatClient;\n this.properties = properties;\n this.userSettingsService = userSettingsService;\n this.connectionResolver = connectionResolver;\n }\n\n @Transactional\n public SendCenterAiBatchResponse generate(CurrentUser currentUser, SendCenterAiGenerateRequest request) {\n String apiKeyOverride = resolvePersonalAiApiKey(currentUser);\n ensureAiConfigured(apiKeyOverride);\n mailAccountMapper.findByIdAndUserId(request.accountId(), currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"邮箱账号不存在\"));\n ResolvedSendCenterRecipients resolved = recipientResolver.resolve(\n currentUser.id(), request.profileIds(), request.groupIds(), request.manualRecipients());\n if (resolved.recipients().isEmpty()) {\n throw new IllegalArgumentException(\"请至少选择一个收件人\");\n }\n if (safe(request.userInstruction()).isBlank() && safe(request.baseSubject()).isBlank() && safe(request.baseTextBody()).isBlank()) {\n throw new IllegalArgumentException(\"请提供群发写作要求或基础草稿\");\n }\n\n SendCenterAiBatch batch = buildBatch(currentUser.id(), request, resolved.recipients().size());\n mapper.insertBatch(batch);\n Map itemByEmail = insertPendingItems(batch.getId(), resolved.recipients());\n try {\n AiBatchDraftResponse aiResponse = readAiBatchDraftResponse(\n aiChatClient.completeJson(SYSTEM_PROMPT, buildBatchPrompt(request, resolved), apiKeyOverride)\n );\n applyGeneratedItems(batch, itemByEmail, aiResponse.items());\n } catch (JsonProcessingException ex) {\n markAllFailed(batch, itemByEmail, \"AI 返回格式异常\");\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n }\n return get(currentUser, batch.getId());\n }\n\n public SendCenterAiBatchResponse get(CurrentUser currentUser, Long batchId) {\n SendCenterAiBatch batch = getOwnedBatch(currentUser, batchId);\n return toBatchResponse(batch, mapper.findItemsByBatchId(batch.getId()));\n }\n\n @Transactional\n public SendCenterAiItemResponse updateItem(CurrentUser currentUser, Long batchId, Long itemId, SendCenterAiUpdateItemRequest request) {\n getOwnedBatch(currentUser, batchId);\n SendCenterAiItem item = getOwnedItem(currentUser, batchId, itemId);\n item.setSubject(request.subject());\n item.setTextBody(request.textBody());\n item.setHtmlBody(request.htmlBody());\n item.setStatus(\"GENERATED\");\n item.setErrorMessage(null);\n item.setGeneratedAt(LocalDateTime.now());\n mapper.updateItem(item);\n refreshBatchStatus(batchId, currentUser.id());\n return toItemResponse(getOwnedItem(currentUser, batchId, itemId));\n }\n\n @Transactional\n public SendCenterAiItemResponse regenerateItem(CurrentUser currentUser, Long batchId, Long itemId, SendCenterAiRegenerateRequest request) {\n String apiKeyOverride = resolvePersonalAiApiKey(currentUser);\n ensureAiConfigured(apiKeyOverride);\n SendCenterAiBatch batch = getOwnedBatch(currentUser, batchId);\n SendCenterAiItem item = getOwnedItem(currentUser, batchId, itemId);\n try {\n AiBatchDraftResponse response = readAiBatchDraftResponse(\n aiChatClient.completeJson(SYSTEM_PROMPT, buildRegeneratePrompt(batch, item, request), apiKeyOverride)\n );\n AiBatchDraftItem generated = response.items() == null || response.items().isEmpty() ? null : response.items().get(0);\n if (generated == null) {\n applyFailure(item, request.feedback(), \"AI 未返回该联系人草稿\");\n } else if (safe(generated.subject()).isBlank() || safe(generated.textBody()).isBlank()) {\n applyFailure(item, request.feedback(), safe(generated.errorMessage()).isBlank() ? \"AI 返回草稿缺少主题或正文\" : generated.errorMessage());\n } else {\n applyGenerated(item, generated, request.feedback());\n }\n mapper.updateItem(item);\n refreshBatchStatus(batchId, currentUser.id());\n return toItemResponse(getOwnedItem(currentUser, batchId, itemId));\n } catch (JsonProcessingException ex) {\n applyFailure(item, request.feedback(), \"AI 返回格式异常\");\n mapper.updateItem(item);\n refreshBatchStatus(batchId, currentUser.id());\n return toItemResponse(getOwnedItem(currentUser, batchId, itemId));\n }\n }\n\n @Transactional\n public MailBatchSendResponse sendPersonalized(CurrentUser currentUser, Long batchId, SendCenterPersonalizedSendRequest request) {\n SendCenterAiBatch batch = getOwnedBatch(currentUser, batchId);\n MailAccount account = mailAccountMapper.findByIdAndUserId(batch.getAccountId(), currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"邮箱账号不存在\"));\n String authSecret = resolveSendAuthSecret(account);\n List selectedItemIds = distinctIds(request == null ? null : request.itemIds());\n List sendItems = mapper.findItemsByBatchId(batchId).stream()\n .filter(item -> selectedItemIds.isEmpty() || selectedItemIds.contains(item.getId()))\n .filter(item -> \"GENERATED\".equals(item.getStatus()))\n .filter(item -> !safe(item.getSubject()).isBlank() && !safe(item.getTextBody()).isBlank())\n .toList();\n if (sendItems.isEmpty()) {\n throw new IllegalArgumentException(\"没有可发送的 AI 个性化草稿\");\n }\n\n List responses = new ArrayList<>();\n List historyItems = new ArrayList<>();\n for (SendCenterAiItem item : sendItems) {\n try {\n MailSendResult result = mailSender.send(toSendCommand(account, authSecret, item));\n responses.add(new MailBatchSendItemResponse(item.getProfileId(), item.getDisplayName(), item.getEmailAddress(), item.getSource(), true, null, result.messageId(), result.sentAt()));\n historyItems.add(new SendHistoryItemCreate(\n item.getProfileId(), item.getDisplayName(), item.getEmailAddress(), item.getSource(),\n item.getSubject(), item.getTextBody(), item.getHtmlBody(), true, null, result.messageId(), result.sentAt()));\n } catch (RuntimeException ex) {\n responses.add(new MailBatchSendItemResponse(item.getProfileId(), item.getDisplayName(), item.getEmailAddress(), item.getSource(), false, ex.getMessage(), null, null));\n historyItems.add(new SendHistoryItemCreate(\n item.getProfileId(), item.getDisplayName(), item.getEmailAddress(), item.getSource(),\n null, null, null, false, ex.getMessage(), null, null));\n }\n }\n int successCount = (int) responses.stream().filter(MailBatchSendItemResponse::success).count();\n sendHistoryService.recordPersonalizedBatch(currentUser.id(), batch.getAccountId(), account.getEmailAddress(), \"AI 个性化群发\", historyItems);\n return new MailBatchSendResponse(responses.size(), successCount, responses.size() - successCount, responses);\n }\n\n private SendCenterAiBatch buildBatch(Long userId, SendCenterAiGenerateRequest request, int totalCount) {\n SendCenterAiBatch batch = new SendCenterAiBatch();\n batch.setUserId(userId);\n batch.setAccountId(request.accountId());\n batch.setProfileIds(writeJson(distinctIds(request.profileIds())));\n batch.setGroupIds(writeJson(distinctIds(request.groupIds())));\n batch.setManualRecipients(writeJson(normalizeManualRecipients(request.manualRecipients())));\n batch.setUserInstruction(request.userInstruction());\n batch.setBaseSubject(request.baseSubject());\n batch.setBaseTextBody(request.baseTextBody());\n batch.setModel(properties.getModel());\n batch.setStatus(\"GENERATING\");\n batch.setTotalCount(totalCount);\n batch.setGeneratedCount(0);\n batch.setFailedCount(0);\n return batch;\n }\n\n private Map insertPendingItems(Long batchId, List recipients) {\n Map itemByEmail = new LinkedHashMap<>();\n for (ResolvedRecipient recipient : recipients) {\n SendCenterAiItem item = new SendCenterAiItem();\n item.setBatchId(batchId);\n item.setProfileId(recipient.profileId());\n item.setDisplayName(recipient.displayName());\n item.setEmailAddress(recipient.emailAddress());\n item.setSource(recipient.source());\n item.setStatus(\"PENDING\");\n mapper.insertItem(item);\n itemByEmail.put(recipient.emailAddress(), item);\n }\n return itemByEmail;\n }\n\n private void applyGeneratedItems(SendCenterAiBatch batch, Map itemByEmail, List generatedItems) {\n Map generatedByEmail = new LinkedHashMap<>();\n for (AiBatchDraftItem generated : generatedItems == null ? List.of() : generatedItems) {\n String email = normalizeEmail(generated.emailAddress());\n if (!email.isBlank() && itemByEmail.containsKey(email)) {\n generatedByEmail.put(email, generated);\n }\n }\n for (Map.Entry entry : itemByEmail.entrySet()) {\n AiBatchDraftItem generated = generatedByEmail.get(entry.getKey());\n SendCenterAiItem item = entry.getValue();\n if (generated == null) {\n applyFailure(item, null, \"AI 未返回该联系人草稿\");\n } else if (safe(generated.subject()).isBlank() || safe(generated.textBody()).isBlank()) {\n applyFailure(item, null, safe(generated.errorMessage()).isBlank() ? \"AI 返回草稿缺少主题或正文\" : generated.errorMessage());\n } else {\n applyGenerated(item, generated, null);\n }\n mapper.updateItem(item);\n }\n refreshBatchStatus(batch.getId(), batch.getUserId());\n }\n\n private void markAllFailed(SendCenterAiBatch batch, Map itemByEmail, String errorMessage) {\n for (SendCenterAiItem item : itemByEmail.values()) {\n applyFailure(item, null, errorMessage);\n mapper.updateItem(item);\n }\n refreshBatchStatus(batch.getId(), batch.getUserId());\n }\n\n private void applyGenerated(SendCenterAiItem item, AiBatchDraftItem generated, String feedback) {\n item.setSubject(generated.subject());\n item.setTextBody(generated.textBody());\n item.setHtmlBody(generated.htmlBody());\n item.setStatus(\"GENERATED\");\n item.setSafetyLevel(safe(generated.safetyLevel()).isBlank() ? \"LOW\" : generated.safetyLevel());\n item.setSummary(generated.summary());\n item.setImprovementsJson(writeJson(generated.improvements() == null ? List.of() : generated.improvements()));\n item.setRisksJson(writeJson(generated.risks() == null ? List.of() : generated.risks()));\n item.setLastFeedback(feedback);\n item.setErrorMessage(null);\n item.setGeneratedAt(LocalDateTime.now());\n }\n\n private void applyFailure(SendCenterAiItem item, String feedback, String errorMessage) {\n item.setStatus(\"FAILED\");\n item.setLastFeedback(feedback);\n item.setErrorMessage(errorMessage);\n item.setGeneratedAt(LocalDateTime.now());\n }\n\n private void refreshBatchStatus(Long batchId, Long userId) {\n SendCenterAiBatch batch = mapper.findBatchByIdAndUserId(batchId, userId)\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 群发批次不存在\"));\n List items = mapper.findItemsByBatchId(batchId);\n int generatedCount = (int) items.stream().filter(item -> \"GENERATED\".equals(item.getStatus())).count();\n int failedCount = (int) items.stream().filter(item -> \"FAILED\".equals(item.getStatus())).count();\n batch.setGeneratedCount(generatedCount);\n batch.setFailedCount(failedCount);\n if (generatedCount == items.size()) {\n batch.setStatus(\"GENERATED\");\n } else if (generatedCount > 0) {\n batch.setStatus(\"PARTIAL_FAILED\");\n } else if (failedCount == items.size()) {\n batch.setStatus(\"FAILED\");\n } else {\n batch.setStatus(\"GENERATING\");\n }\n mapper.updateBatchStatus(batch);\n }\n\n private String buildBatchPrompt(SendCenterAiGenerateRequest request, ResolvedSendCenterRecipients resolved) throws JsonProcessingException {\n return truncatePrompt(\"\"\"\n 请为下面去重后的联系人分别生成个性化邮件,并严格输出 JSON:\n {\n \"items\": [\n {\n \"emailAddress\": \"必须原样返回联系人邮箱\",\n \"subject\": \"邮件主题\",\n \"textBody\": \"纯文本正文\",\n \"htmlBody\": null,\n \"safetyLevel\": \"LOW | MEDIUM | HIGH\",\n \"summary\": \"一句话说明生成策略\",\n \"improvements\": [\"相对基础稿的改进点\"],\n \"risks\": [\"发送前注意点\"],\n \"errorMessage\": null\n }\n ]\n }\n\n 群发写作要求:%s\n 基础主题:%s\n 基础正文:%s\n\n 群组上下文:\n %s\n\n 联系人列表:\n %s\n \"\"\".formatted(\n blankAsNone(request.userInstruction()),\n blankAsNone(request.baseSubject()),\n blankAsNone(request.baseTextBody()),\n objectMapper.writeValueAsString(resolved.groups().stream().map(this::groupContext).toList()),\n objectMapper.writeValueAsString(resolved.recipients().stream().map(this::recipientContext).toList())\n ));\n }\n\n private String buildRegeneratePrompt(SendCenterAiBatch batch, SendCenterAiItem item, SendCenterAiRegenerateRequest request) throws JsonProcessingException {\n return truncatePrompt(\"\"\"\n 请根据用户反馈,只为这一个联系人重新生成一封个性化邮件。\n 必须只输出下面这个 JSON 对象,不要输出 Markdown、代码块、解释文本或额外字段;items 必须只包含一个元素。\n 如果反馈要求无法满足,也必须返回合法 JSON,并在 errorMessage 中说明原因。\n\n {\n \"items\": [\n {\n \"emailAddress\": \"%s\",\n \"subject\": \"邮件主题\",\n \"textBody\": \"纯文本正文\",\n \"htmlBody\": null,\n \"safetyLevel\": \"LOW\",\n \"summary\": \"一句话说明如何根据反馈调整\",\n \"improvements\": [\"相对当前草稿的改进点\"],\n \"risks\": [],\n \"errorMessage\": null\n }\n ]\n }\n\n 原始群发写作要求:%s\n 基础主题:%s\n 基础正文:%s\n 联系人:%s\n 当前主题:%s\n 当前正文:%s\n 用户反馈:%s\n \"\"\".formatted(\n item.getEmailAddress(),\n blankAsNone(batch.getUserInstruction()),\n blankAsNone(batch.getBaseSubject()),\n blankAsNone(batch.getBaseTextBody()),\n objectMapper.writeValueAsString(Map.of(\n \"emailAddress\", item.getEmailAddress(),\n \"displayName\", safe(item.getDisplayName()),\n \"source\", safe(item.getSource())\n )),\n blankAsNone(request.currentSubject() == null ? item.getSubject() : request.currentSubject()),\n blankAsNone(request.currentTextBody() == null ? item.getTextBody() : request.currentTextBody()),\n request.feedback()\n ));\n }\n\n private Map recipientContext(ResolvedRecipient recipient) {\n Map context = new LinkedHashMap<>();\n context.put(\"emailAddress\", recipient.emailAddress());\n context.put(\"displayName\", safe(recipient.displayName()));\n context.put(\"source\", recipient.source());\n context.put(\"role\", safe(recipient.role()));\n context.put(\"relationship\", safe(recipient.relationship()));\n context.put(\"personality\", safe(recipient.personality()));\n context.put(\"communicationPreference\", safe(recipient.communicationPreference()));\n context.put(\"notes\", safe(recipient.notes()));\n if (recipient.group() != null) {\n context.put(\"group\", groupContext(recipient.group()));\n }\n return context;\n }\n\n private Map groupContext(RecipientGroup group) {\n Map context = new LinkedHashMap<>();\n context.put(\"name\", group.getName());\n context.put(\"description\", safe(group.getDescription()));\n context.put(\"scenario\", safe(group.getScenario()));\n context.put(\"tonePreference\", safe(group.getTonePreference()));\n context.put(\"bulkSendGuidance\", safe(group.getBulkSendGuidance()));\n return context;\n }\n\n private MailSendCommand toSendCommand(MailAccount account, String authSecret, SendCenterAiItem item) {\n return new MailSendCommand(\n account.getProvider(),\n account.getSmtpHost(),\n account.getSmtpPort(),\n account.getSmtpSsl(),\n account.getSmtpStartTls(),\n account.getAuthUsername(),\n authSecret,\n connectionResolver.proxyConfig(account),\n account.getEmailAddress(),\n item.getEmailAddress(),\n item.getSubject(),\n item.getTextBody(),\n item.getHtmlBody()\n );\n }\n\n private SendCenterAiBatch getOwnedBatch(CurrentUser currentUser, Long batchId) {\n return mapper.findBatchByIdAndUserId(batchId, currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 群发批次不存在\"));\n }\n\n private SendCenterAiItem getOwnedItem(CurrentUser currentUser, Long batchId, Long itemId) {\n return mapper.findItemByIdAndBatchIdAndUserId(itemId, batchId, currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 群发联系人草稿不存在\"));\n }\n\n private SendCenterAiBatchResponse toBatchResponse(SendCenterAiBatch batch, List items) {\n return new SendCenterAiBatchResponse(\n batch.getId(),\n batch.getAccountId(),\n readLongList(batch.getProfileIds()),\n readLongList(batch.getGroupIds()),\n readStringList(batch.getManualRecipients()),\n batch.getUserInstruction(),\n batch.getBaseSubject(),\n batch.getBaseTextBody(),\n batch.getModel(),\n batch.getStatus(),\n batch.getTotalCount(),\n batch.getGeneratedCount(),\n batch.getFailedCount(),\n batch.getCreatedAt(),\n batch.getUpdatedAt(),\n items.stream().map(this::toItemResponse).toList()\n );\n }\n\n private SendCenterAiItemResponse toItemResponse(SendCenterAiItem item) {\n return new SendCenterAiItemResponse(\n item.getId(),\n item.getProfileId(),\n item.getDisplayName(),\n item.getEmailAddress(),\n item.getSource(),\n item.getSubject(),\n item.getTextBody(),\n item.getHtmlBody(),\n item.getStatus(),\n item.getSafetyLevel(),\n item.getSummary(),\n readStringList(item.getImprovementsJson()),\n readStringList(item.getRisksJson()),\n item.getLastFeedback(),\n item.getErrorMessage(),\n item.getGeneratedAt(),\n item.getCreatedAt(),\n item.getUpdatedAt()\n );\n }\n\n private void ensureAiConfigured(String apiKeyOverride) {\n if (!properties.isEnabled()) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未启用\");\n }\n if ((apiKeyOverride == null || apiKeyOverride.isBlank()) && (properties.getApiKey() == null || properties.getApiKey().isBlank())) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未配置,请先设置 DEEPSEEK_API_KEY 或个人 AI API Key\");\n }\n }\n\n private String resolvePersonalAiApiKey(CurrentUser currentUser) {\n return userSettingsService.resolveAiApiKey(currentUser.id());\n }\n\n private String resolveSendAuthSecret(MailAccount account) {\n if (!Boolean.TRUE.equals(account.getEnabled())) {\n throw new IllegalArgumentException(\"邮箱账号已禁用,无法发送邮件\");\n }\n String authSecret = secretCryptoService.decrypt(account.getAuthSecretEncrypted());\n if (account.getAuthUsername() == null || account.getAuthUsername().isBlank() || authSecret == null || authSecret.isBlank()) {\n throw new IllegalArgumentException(\"发送邮件需要邮箱账号和授权码\");\n }\n return authSecret;\n }\n\n private List distinctIds(List ids) {\n if (ids == null || ids.isEmpty()) return List.of();\n return ids.stream().filter(id -> id != null)\n .collect(LinkedHashSet::new, LinkedHashSet::add, LinkedHashSet::addAll)\n .stream().toList();\n }\n\n private List normalizeManualRecipients(List recipients) {\n if (recipients == null || recipients.isEmpty()) return List.of();\n return recipients.stream().map(this::normalizeEmail).filter(email -> !email.isBlank())\n .collect(LinkedHashSet::new, LinkedHashSet::add, LinkedHashSet::addAll)\n .stream().toList();\n }\n\n private String normalizeEmail(String email) {\n return email == null ? \"\" : email.trim().toLowerCase(Locale.ROOT);\n }\n\n private String truncatePrompt(String prompt) {\n int maxChars = Math.max(1000, properties.getMaxInputChars());\n return prompt.length() <= maxChars ? prompt : prompt.substring(0, maxChars);\n }\n\n private AiBatchDraftResponse readAiBatchDraftResponse(String content) throws JsonProcessingException {\n return objectMapper.readValue(extractJsonObject(content), AiBatchDraftResponse.class);\n }\n\n private String extractJsonObject(String content) {\n String text = safe(content).trim();\n if (text.startsWith(\"```\")) {\n int firstLineEnd = text.indexOf('\\n');\n int fenceEnd = text.lastIndexOf(\"```\");\n if (firstLineEnd >= 0 && fenceEnd > firstLineEnd) {\n text = text.substring(firstLineEnd + 1, fenceEnd).trim();\n }\n }\n int start = text.indexOf('{');\n int end = text.lastIndexOf('}');\n if (start >= 0 && end > start) {\n return text.substring(start, end + 1);\n }\n return text;\n }\n\n private String blankAsNone(String value) {\n return safe(value).isBlank() ? \"无\" : value;\n }\n\n private String safe(Object value) {\n return value == null ? \"\" : value.toString();\n }\n\n private String writeJson(Object value) {\n try {\n return objectMapper.writeValueAsString(value);\n } catch (JsonProcessingException ex) {\n throw new IllegalStateException(\"AI 群发数据序列化失败\", ex);\n }\n }\n\n private List readLongList(String json) {\n if (json == null || json.isBlank()) return List.of();\n try {\n return objectMapper.readValue(json, LONG_LIST_TYPE);\n } catch (JsonProcessingException ex) {\n throw new IllegalStateException(\"AI 群发批次数据解析失败\", ex);\n }\n }\n\n private List readStringList(String json) {\n if (json == null || json.isBlank()) return List.of();\n try {\n return objectMapper.readValue(json, STRING_LIST_TYPE);\n } catch (JsonProcessingException ex) {\n throw new IllegalStateException(\"AI 群发条目数据解析失败\", ex);\n }\n }\n}\n"}, "c95da9c1fa86d9f5dc8d7eceab439336de949245": {"t": 1785751007.5858667, "n": 26680, "h": "81880167084145af", "c": "package com.mailnest.sendcenter.ai;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.core.type.TypeReference;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.mailnest.common.ResourceNotFoundException;\nimport com.mailnest.mailaccount.MailAccount;\nimport com.mailnest.mailaccount.MailAccountMapper;\nimport com.mailnest.mailaccount.MailAccountConnectionResolver;\nimport com.mailnest.mailai.AiChatClient;\nimport com.mailnest.mailai.MailAiException;\nimport com.mailnest.mailai.MailAiProperties;\nimport com.mailnest.mailmessage.dto.MailBatchSendItemResponse;\nimport com.mailnest.mailmessage.dto.MailBatchSendResponse;\nimport com.mailnest.mailprotocol.MailSendCommand;\nimport com.mailnest.mailprotocol.MailSendResult;\nimport com.mailnest.mailprotocol.MailSender;\nimport com.mailnest.recipient.RecipientGroup;\nimport com.mailnest.security.CurrentUser;\nimport com.mailnest.security.secret.SecretCryptoService;\nimport com.mailnest.settings.UserSettingsService;\nimport com.mailnest.sendcenter.ai.SendCenterRecipientResolver.ResolvedRecipient;\nimport com.mailnest.sendcenter.ai.SendCenterRecipientResolver.ResolvedSendCenterRecipients;\nimport com.mailnest.sendcenter.ai.dto.AiBatchDraftItem;\nimport com.mailnest.sendcenter.ai.dto.AiBatchDraftResponse;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiBatchResponse;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiGenerateRequest;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiItemResponse;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiRegenerateRequest;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiUpdateItemRequest;\nimport com.mailnest.sendcenter.ai.dto.SendCenterPersonalizedSendRequest;\nimport com.mailnest.sendhistory.SendHistoryService;\nimport com.mailnest.sendhistory.SendHistoryService.SendHistoryItemCreate;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.LocalDateTime;\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\n\n@Service\npublic class SendCenterAiService {\n private static final String SYSTEM_PROMPT = \"\"\"\n 你是 MailNest 的发信中心 AI 群发助手,负责为用户选定的每个收件人生成个性化中文邮件草稿。\n 你必须只输出合法 JSON,不要输出 Markdown,不要添加解释。\n 不要编造事实、日期、附件、承诺、历史关系或用户身份;信息不足时使用稳妥表达。\n 每个收件人必须只生成给该收件人的一封邮件,不能泄露其他收件人或群发名单。\n \"\"\";\n\n private static final TypeReference> LONG_LIST_TYPE = new TypeReference<>() {\n };\n private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {\n };\n\n private final SendCenterAiBatchMapper mapper;\n private final SendCenterRecipientResolver recipientResolver;\n private final MailAccountMapper mailAccountMapper;\n private final SecretCryptoService secretCryptoService;\n private final MailSender mailSender;\n private final SendHistoryService sendHistoryService;\n private final AiChatClient aiChatClient;\n private final MailAiProperties properties;\n private final UserSettingsService userSettingsService;\n private final MailAccountConnectionResolver connectionResolver;\n private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();\n\n public SendCenterAiService(SendCenterAiBatchMapper mapper,\n SendCenterRecipientResolver recipientResolver,\n MailAccountMapper mailAccountMapper,\n SecretCryptoService secretCryptoService,\n MailSender mailSender,\n SendHistoryService sendHistoryService,\n AiChatClient aiChatClient,\n MailAiProperties properties,\n UserSettingsService userSettingsService,\n MailAccountConnectionResolver connectionResolver) {\n this.mapper = mapper;\n this.recipientResolver = recipientResolver;\n this.mailAccountMapper = mailAccountMapper;\n this.secretCryptoService = secretCryptoService;\n this.mailSender = mailSender;\n this.sendHistoryService = sendHistoryService;\n this.aiChatClient = aiChatClient;\n this.properties = properties;\n this.userSettingsService = userSettingsService;\n this.connectionResolver = connectionResolver;\n }\n\n @Transactional\n public SendCenterAiBatchResponse generate(CurrentUser currentUser, SendCenterAiGenerateRequest request) {\n String apiKeyOverride = resolvePersonalAiApiKey(currentUser);\n ensureAiConfigured(apiKeyOverride);\n mailAccountMapper.findByIdAndUserId(request.accountId(), currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"邮箱账号不存在\"));\n ResolvedSendCenterRecipients resolved = recipientResolver.resolve(\n currentUser.id(), request.profileIds(), request.groupIds(), request.manualRecipients());\n if (resolved.recipients().isEmpty()) {\n throw new IllegalArgumentException(\"请至少选择一个收件人\");\n }\n if (safe(request.userInstruction()).isBlank() && safe(request.baseSubject()).isBlank() && safe(request.baseTextBody()).isBlank()) {\n throw new IllegalArgumentException(\"请提供群发写作要求或基础草稿\");\n }\n\n SendCenterAiBatch batch = buildBatch(currentUser.id(), request, resolved.recipients().size());\n mapper.insertBatch(batch);\n Map itemByEmail = insertPendingItems(batch.getId(), resolved.recipients());\n try {\n AiBatchDraftResponse aiResponse = objectMapper.readValue(\n aiChatClient.completeJson(SYSTEM_PROMPT, buildBatchPrompt(request, resolved), apiKeyOverride),\n AiBatchDraftResponse.class\n );\n applyGeneratedItems(batch, itemByEmail, aiResponse.items());\n } catch (JsonProcessingException ex) {\n markAllFailed(batch, itemByEmail, \"AI 返回格式异常\");\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n }\n return get(currentUser, batch.getId());\n }\n\n public SendCenterAiBatchResponse get(CurrentUser currentUser, Long batchId) {\n SendCenterAiBatch batch = getOwnedBatch(currentUser, batchId);\n return toBatchResponse(batch, mapper.findItemsByBatchId(batch.getId()));\n }\n\n @Transactional\n public SendCenterAiItemResponse updateItem(CurrentUser currentUser, Long batchId, Long itemId, SendCenterAiUpdateItemRequest request) {\n getOwnedBatch(currentUser, batchId);\n SendCenterAiItem item = getOwnedItem(currentUser, batchId, itemId);\n item.setSubject(request.subject());\n item.setTextBody(request.textBody());\n item.setHtmlBody(request.htmlBody());\n item.setStatus(\"GENERATED\");\n item.setErrorMessage(null);\n item.setGeneratedAt(LocalDateTime.now());\n mapper.updateItem(item);\n refreshBatchStatus(batchId, currentUser.id());\n return toItemResponse(getOwnedItem(currentUser, batchId, itemId));\n }\n\n @Transactional\n public SendCenterAiItemResponse regenerateItem(CurrentUser currentUser, Long batchId, Long itemId, SendCenterAiRegenerateRequest request) {\n String apiKeyOverride = resolvePersonalAiApiKey(currentUser);\n ensureAiConfigured(apiKeyOverride);\n SendCenterAiBatch batch = getOwnedBatch(currentUser, batchId);\n SendCenterAiItem item = getOwnedItem(currentUser, batchId, itemId);\n try {\n AiBatchDraftResponse response = objectMapper.readValue(\n aiChatClient.completeJson(SYSTEM_PROMPT, buildRegeneratePrompt(batch, item, request), apiKeyOverride),\n AiBatchDraftResponse.class\n );\n AiBatchDraftItem generated = response.items() == null || response.items().isEmpty() ? null : response.items().get(0);\n if (generated == null) {\n applyFailure(item, request.feedback(), \"AI 未返回该联系人草稿\");\n } else {\n applyGenerated(item, generated, request.feedback());\n }\n mapper.updateItem(item);\n refreshBatchStatus(batchId, currentUser.id());\n return toItemResponse(getOwnedItem(currentUser, batchId, itemId));\n } catch (JsonProcessingException ex) {\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n }\n }\n\n @Transactional\n public MailBatchSendResponse sendPersonalized(CurrentUser currentUser, Long batchId, SendCenterPersonalizedSendRequest request) {\n SendCenterAiBatch batch = getOwnedBatch(currentUser, batchId);\n MailAccount account = mailAccountMapper.findByIdAndUserId(batch.getAccountId(), currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"邮箱账号不存在\"));\n String authSecret = resolveSendAuthSecret(account);\n List selectedItemIds = distinctIds(request == null ? null : request.itemIds());\n List sendItems = mapper.findItemsByBatchId(batchId).stream()\n .filter(item -> selectedItemIds.isEmpty() || selectedItemIds.contains(item.getId()))\n .filter(item -> \"GENERATED\".equals(item.getStatus()))\n .filter(item -> !safe(item.getSubject()).isBlank() && !safe(item.getTextBody()).isBlank())\n .toList();\n if (sendItems.isEmpty()) {\n throw new IllegalArgumentException(\"没有可发送的 AI 个性化草稿\");\n }\n\n List responses = new ArrayList<>();\n List historyItems = new ArrayList<>();\n for (SendCenterAiItem item : sendItems) {\n try {\n MailSendResult result = mailSender.send(toSendCommand(account, authSecret, item));\n responses.add(new MailBatchSendItemResponse(item.getProfileId(), item.getDisplayName(), item.getEmailAddress(), item.getSource(), true, null, result.messageId(), result.sentAt()));\n historyItems.add(new SendHistoryItemCreate(\n item.getProfileId(), item.getDisplayName(), item.getEmailAddress(), item.getSource(),\n item.getSubject(), item.getTextBody(), item.getHtmlBody(), true, null, result.messageId(), result.sentAt()));\n } catch (RuntimeException ex) {\n responses.add(new MailBatchSendItemResponse(item.getProfileId(), item.getDisplayName(), item.getEmailAddress(), item.getSource(), false, ex.getMessage(), null, null));\n historyItems.add(new SendHistoryItemCreate(\n item.getProfileId(), item.getDisplayName(), item.getEmailAddress(), item.getSource(),\n null, null, null, false, ex.getMessage(), null, null));\n }\n }\n int successCount = (int) responses.stream().filter(MailBatchSendItemResponse::success).count();\n sendHistoryService.recordPersonalizedBatch(currentUser.id(), batch.getAccountId(), account.getEmailAddress(), \"AI 个性化群发\", historyItems);\n return new MailBatchSendResponse(responses.size(), successCount, responses.size() - successCount, responses);\n }\n\n private SendCenterAiBatch buildBatch(Long userId, SendCenterAiGenerateRequest request, int totalCount) {\n SendCenterAiBatch batch = new SendCenterAiBatch();\n batch.setUserId(userId);\n batch.setAccountId(request.accountId());\n batch.setProfileIds(writeJson(distinctIds(request.profileIds())));\n batch.setGroupIds(writeJson(distinctIds(request.groupIds())));\n batch.setManualRecipients(writeJson(normalizeManualRecipients(request.manualRecipients())));\n batch.setUserInstruction(request.userInstruction());\n batch.setBaseSubject(request.baseSubject());\n batch.setBaseTextBody(request.baseTextBody());\n batch.setModel(properties.getModel());\n batch.setStatus(\"GENERATING\");\n batch.setTotalCount(totalCount);\n batch.setGeneratedCount(0);\n batch.setFailedCount(0);\n return batch;\n }\n\n private Map insertPendingItems(Long batchId, List recipients) {\n Map itemByEmail = new LinkedHashMap<>();\n for (ResolvedRecipient recipient : recipients) {\n SendCenterAiItem item = new SendCenterAiItem();\n item.setBatchId(batchId);\n item.setProfileId(recipient.profileId());\n item.setDisplayName(recipient.displayName());\n item.setEmailAddress(recipient.emailAddress());\n item.setSource(recipient.source());\n item.setStatus(\"PENDING\");\n mapper.insertItem(item);\n itemByEmail.put(recipient.emailAddress(), item);\n }\n return itemByEmail;\n }\n\n private void applyGeneratedItems(SendCenterAiBatch batch, Map itemByEmail, List generatedItems) {\n Map generatedByEmail = new LinkedHashMap<>();\n for (AiBatchDraftItem generated : generatedItems == null ? List.of() : generatedItems) {\n String email = normalizeEmail(generated.emailAddress());\n if (!email.isBlank() && itemByEmail.containsKey(email)) {\n generatedByEmail.put(email, generated);\n }\n }\n for (Map.Entry entry : itemByEmail.entrySet()) {\n AiBatchDraftItem generated = generatedByEmail.get(entry.getKey());\n SendCenterAiItem item = entry.getValue();\n if (generated == null) {\n applyFailure(item, null, \"AI 未返回该联系人草稿\");\n } else if (safe(generated.subject()).isBlank() || safe(generated.textBody()).isBlank()) {\n applyFailure(item, null, safe(generated.errorMessage()).isBlank() ? \"AI 返回草稿缺少主题或正文\" : generated.errorMessage());\n } else {\n applyGenerated(item, generated, null);\n }\n mapper.updateItem(item);\n }\n refreshBatchStatus(batch.getId(), batch.getUserId());\n }\n\n private void markAllFailed(SendCenterAiBatch batch, Map itemByEmail, String errorMessage) {\n for (SendCenterAiItem item : itemByEmail.values()) {\n applyFailure(item, null, errorMessage);\n mapper.updateItem(item);\n }\n refreshBatchStatus(batch.getId(), batch.getUserId());\n }\n\n private void applyGenerated(SendCenterAiItem item, AiBatchDraftItem generated, String feedback) {\n item.setSubject(generated.subject());\n item.setTextBody(generated.textBody());\n item.setHtmlBody(generated.htmlBody());\n item.setStatus(\"GENERATED\");\n item.setSafetyLevel(safe(generated.safetyLevel()).isBlank() ? \"LOW\" : generated.safetyLevel());\n item.setSummary(generated.summary());\n item.setImprovementsJson(writeJson(generated.improvements() == null ? List.of() : generated.improvements()));\n item.setRisksJson(writeJson(generated.risks() == null ? List.of() : generated.risks()));\n item.setLastFeedback(feedback);\n item.setErrorMessage(null);\n item.setGeneratedAt(LocalDateTime.now());\n }\n\n private void applyFailure(SendCenterAiItem item, String feedback, String errorMessage) {\n item.setStatus(\"FAILED\");\n item.setLastFeedback(feedback);\n item.setErrorMessage(errorMessage);\n item.setGeneratedAt(LocalDateTime.now());\n }\n\n private void refreshBatchStatus(Long batchId, Long userId) {\n SendCenterAiBatch batch = mapper.findBatchByIdAndUserId(batchId, userId)\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 群发批次不存在\"));\n List items = mapper.findItemsByBatchId(batchId);\n int generatedCount = (int) items.stream().filter(item -> \"GENERATED\".equals(item.getStatus())).count();\n int failedCount = (int) items.stream().filter(item -> \"FAILED\".equals(item.getStatus())).count();\n batch.setGeneratedCount(generatedCount);\n batch.setFailedCount(failedCount);\n if (generatedCount == items.size()) {\n batch.setStatus(\"GENERATED\");\n } else if (generatedCount > 0) {\n batch.setStatus(\"PARTIAL_FAILED\");\n } else if (failedCount == items.size()) {\n batch.setStatus(\"FAILED\");\n } else {\n batch.setStatus(\"GENERATING\");\n }\n mapper.updateBatchStatus(batch);\n }\n\n private String buildBatchPrompt(SendCenterAiGenerateRequest request, ResolvedSendCenterRecipients resolved) throws JsonProcessingException {\n return truncatePrompt(\"\"\"\n 请为下面去重后的联系人分别生成个性化邮件,并严格输出 JSON:\n {\n \"items\": [\n {\n \"emailAddress\": \"必须原样返回联系人邮箱\",\n \"subject\": \"邮件主题\",\n \"textBody\": \"纯文本正文\",\n \"htmlBody\": null,\n \"safetyLevel\": \"LOW | MEDIUM | HIGH\",\n \"summary\": \"一句话说明生成策略\",\n \"improvements\": [\"相对基础稿的改进点\"],\n \"risks\": [\"发送前注意点\"],\n \"errorMessage\": null\n }\n ]\n }\n\n 群发写作要求:%s\n 基础主题:%s\n 基础正文:%s\n\n 群组上下文:\n %s\n\n 联系人列表:\n %s\n \"\"\".formatted(\n blankAsNone(request.userInstruction()),\n blankAsNone(request.baseSubject()),\n blankAsNone(request.baseTextBody()),\n objectMapper.writeValueAsString(resolved.groups().stream().map(this::groupContext).toList()),\n objectMapper.writeValueAsString(resolved.recipients().stream().map(this::recipientContext).toList())\n ));\n }\n\n private String buildRegeneratePrompt(SendCenterAiBatch batch, SendCenterAiItem item, SendCenterAiRegenerateRequest request) throws JsonProcessingException {\n return truncatePrompt(\"\"\"\n 请根据用户反馈,只为这一个联系人重新生成一封个性化邮件,严格输出相同 JSON 格式且 items 只包含一个元素。\n\n 原始群发写作要求:%s\n 基础主题:%s\n 基础正文:%s\n 联系人:%s\n 当前主题:%s\n 当前正文:%s\n 用户反馈:%s\n \"\"\".formatted(\n blankAsNone(batch.getUserInstruction()),\n blankAsNone(batch.getBaseSubject()),\n blankAsNone(batch.getBaseTextBody()),\n objectMapper.writeValueAsString(Map.of(\n \"emailAddress\", item.getEmailAddress(),\n \"displayName\", safe(item.getDisplayName()),\n \"source\", safe(item.getSource())\n )),\n blankAsNone(request.currentSubject() == null ? item.getSubject() : request.currentSubject()),\n blankAsNone(request.currentTextBody() == null ? item.getTextBody() : request.currentTextBody()),\n request.feedback()\n ));\n }\n\n private Map recipientContext(ResolvedRecipient recipient) {\n Map context = new LinkedHashMap<>();\n context.put(\"emailAddress\", recipient.emailAddress());\n context.put(\"displayName\", safe(recipient.displayName()));\n context.put(\"source\", recipient.source());\n context.put(\"role\", safe(recipient.role()));\n context.put(\"relationship\", safe(recipient.relationship()));\n context.put(\"personality\", safe(recipient.personality()));\n context.put(\"communicationPreference\", safe(recipient.communicationPreference()));\n context.put(\"notes\", safe(recipient.notes()));\n if (recipient.group() != null) {\n context.put(\"group\", groupContext(recipient.group()));\n }\n return context;\n }\n\n private Map groupContext(RecipientGroup group) {\n Map context = new LinkedHashMap<>();\n context.put(\"name\", group.getName());\n context.put(\"description\", safe(group.getDescription()));\n context.put(\"scenario\", safe(group.getScenario()));\n context.put(\"tonePreference\", safe(group.getTonePreference()));\n context.put(\"bulkSendGuidance\", safe(group.getBulkSendGuidance()));\n return context;\n }\n\n private MailSendCommand toSendCommand(MailAccount account, String authSecret, SendCenterAiItem item) {\n return new MailSendCommand(\n account.getProvider(),\n account.getSmtpHost(),\n account.getSmtpPort(),\n account.getSmtpSsl(),\n account.getSmtpStartTls(),\n account.getAuthUsername(),\n authSecret,\n connectionResolver.proxyConfig(account),\n account.getEmailAddress(),\n item.getEmailAddress(),\n item.getSubject(),\n item.getTextBody(),\n item.getHtmlBody()\n );\n }\n\n private SendCenterAiBatch getOwnedBatch(CurrentUser currentUser, Long batchId) {\n return mapper.findBatchByIdAndUserId(batchId, currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 群发批次不存在\"));\n }\n\n private SendCenterAiItem getOwnedItem(CurrentUser currentUser, Long batchId, Long itemId) {\n return mapper.findItemByIdAndBatchIdAndUserId(itemId, batchId, currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 群发联系人草稿不存在\"));\n }\n\n private SendCenterAiBatchResponse toBatchResponse(SendCenterAiBatch batch, List items) {\n return new SendCenterAiBatchResponse(\n batch.getId(),\n batch.getAccountId(),\n readLongList(batch.getProfileIds()),\n readLongList(batch.getGroupIds()),\n readStringList(batch.getManualRecipients()),\n batch.getUserInstruction(),\n batch.getBaseSubject(),\n batch.getBaseTextBody(),\n batch.getModel(),\n batch.getStatus(),\n batch.getTotalCount(),\n batch.getGeneratedCount(),\n batch.getFailedCount(),\n batch.getCreatedAt(),\n batch.getUpdatedAt(),\n items.stream().map(this::toItemResponse).toList()\n );\n }\n\n private SendCenterAiItemResponse toItemResponse(SendCenterAiItem item) {\n return new SendCenterAiItemResponse(\n item.getId(),\n item.getProfileId(),\n item.getDisplayName(),\n item.getEmailAddress(),\n item.getSource(),\n item.getSubject(),\n item.getTextBody(),\n item.getHtmlBody(),\n item.getStatus(),\n item.getSafetyLevel(),\n item.getSummary(),\n readStringList(item.getImprovementsJson()),\n readStringList(item.getRisksJson()),\n item.getLastFeedback(),\n item.getErrorMessage(),\n item.getGeneratedAt(),\n item.getCreatedAt(),\n item.getUpdatedAt()\n );\n }\n\n private void ensureAiConfigured(String apiKeyOverride) {\n if (!properties.isEnabled()) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未启用\");\n }\n if ((apiKeyOverride == null || apiKeyOverride.isBlank()) && (properties.getApiKey() == null || properties.getApiKey().isBlank())) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未配置,请先设置 DEEPSEEK_API_KEY 或个人 AI API Key\");\n }\n }\n\n private String resolvePersonalAiApiKey(CurrentUser currentUser) {\n return userSettingsService.resolveAiApiKey(currentUser.id());\n }\n\n private String resolveSendAuthSecret(MailAccount account) {\n if (!Boolean.TRUE.equals(account.getEnabled())) {\n throw new IllegalArgumentException(\"邮箱账号已禁用,无法发送邮件\");\n }\n String authSecret = secretCryptoService.decrypt(account.getAuthSecretEncrypted());\n if (account.getAuthUsername() == null || account.getAuthUsername().isBlank() || authSecret == null || authSecret.isBlank()) {\n throw new IllegalArgumentException(\"发送邮件需要邮箱账号和授权码\");\n }\n return authSecret;\n }\n\n private List distinctIds(List ids) {\n if (ids == null || ids.isEmpty()) return List.of();\n return ids.stream().filter(id -> id != null)\n .collect(LinkedHashSet::new, LinkedHashSet::add, LinkedHashSet::addAll)\n .stream().toList();\n }\n\n private List normalizeManualRecipients(List recipients) {\n if (recipients == null || recipients.isEmpty()) return List.of();\n return recipients.stream().map(this::normalizeEmail).filter(email -> !email.isBlank())\n .collect(LinkedHashSet::new, LinkedHashSet::add, LinkedHashSet::addAll)\n .stream().toList();\n }\n\n private String normalizeEmail(String email) {\n return email == null ? \"\" : email.trim().toLowerCase(Locale.ROOT);\n }\n\n private String truncatePrompt(String prompt) {\n int maxChars = Math.max(1000, properties.getMaxInputChars());\n return prompt.length() <= maxChars ? prompt : prompt.substring(0, maxChars);\n }\n\n private String blankAsNone(String value) {\n return safe(value).isBlank() ? \"无\" : value;\n }\n\n private String safe(Object value) {\n return value == null ? \"\" : value.toString();\n }\n\n private String writeJson(Object value) {\n try {\n return objectMapper.writeValueAsString(value);\n } catch (JsonProcessingException ex) {\n throw new IllegalStateException(\"AI 群发数据序列化失败\", ex);\n }\n }\n\n private List readLongList(String json) {\n if (json == null || json.isBlank()) return List.of();\n try {\n return objectMapper.readValue(json, LONG_LIST_TYPE);\n } catch (JsonProcessingException ex) {\n throw new IllegalStateException(\"AI 群发批次数据解析失败\", ex);\n }\n }\n\n private List readStringList(String json) {\n if (json == null || json.isBlank()) return List.of();\n try {\n return objectMapper.readValue(json, STRING_LIST_TYPE);\n } catch (JsonProcessingException ex) {\n throw new IllegalStateException(\"AI 群发条目数据解析失败\", ex);\n }\n }\n}\n"}, "14b9f0880614c9e3a3132d19776ec7a7559bbc7a": {"t": 1785751008.8306346, "n": 26162, "h": "e69e3073c76fe52f", "c": "package com.mailnest.sendcenter.ai;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.core.type.TypeReference;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.mailnest.common.ResourceNotFoundException;\nimport com.mailnest.mailaccount.MailAccount;\nimport com.mailnest.mailaccount.MailAccountMapper;\nimport com.mailnest.mailaccount.MailAccountConnectionResolver;\nimport com.mailnest.mailai.AiChatClient;\nimport com.mailnest.mailai.MailAiException;\nimport com.mailnest.mailai.MailAiProperties;\nimport com.mailnest.mailmessage.dto.MailBatchSendItemResponse;\nimport com.mailnest.mailmessage.dto.MailBatchSendResponse;\nimport com.mailnest.mailprotocol.MailSendCommand;\nimport com.mailnest.mailprotocol.MailSendResult;\nimport com.mailnest.mailprotocol.MailSender;\nimport com.mailnest.recipient.RecipientGroup;\nimport com.mailnest.security.CurrentUser;\nimport com.mailnest.security.secret.SecretCryptoService;\nimport com.mailnest.settings.UserSettingsService;\nimport com.mailnest.sendcenter.ai.SendCenterRecipientResolver.ResolvedRecipient;\nimport com.mailnest.sendcenter.ai.SendCenterRecipientResolver.ResolvedSendCenterRecipients;\nimport com.mailnest.sendcenter.ai.dto.AiBatchDraftItem;\nimport com.mailnest.sendcenter.ai.dto.AiBatchDraftResponse;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiBatchResponse;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiGenerateRequest;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiItemResponse;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiRegenerateRequest;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiUpdateItemRequest;\nimport com.mailnest.sendcenter.ai.dto.SendCenterPersonalizedSendRequest;\nimport com.mailnest.sendhistory.SendHistoryService;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.LocalDateTime;\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\n\n@Service\npublic class SendCenterAiService {\n private static final String SYSTEM_PROMPT = \"\"\"\n 你是 MailNest 的发信中心 AI 群发助手,负责为用户选定的每个收件人生成个性化中文邮件草稿。\n 你必须只输出合法 JSON,不要输出 Markdown,不要添加解释。\n 不要编造事实、日期、附件、承诺、历史关系或用户身份;信息不足时使用稳妥表达。\n 每个收件人必须只生成给该收件人的一封邮件,不能泄露其他收件人或群发名单。\n \"\"\";\n\n private static final TypeReference> LONG_LIST_TYPE = new TypeReference<>() {\n };\n private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {\n };\n\n private final SendCenterAiBatchMapper mapper;\n private final SendCenterRecipientResolver recipientResolver;\n private final MailAccountMapper mailAccountMapper;\n private final SecretCryptoService secretCryptoService;\n private final MailSender mailSender;\n private final SendHistoryService sendHistoryService;\n private final AiChatClient aiChatClient;\n private final MailAiProperties properties;\n private final UserSettingsService userSettingsService;\n private final MailAccountConnectionResolver connectionResolver;\n private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();\n\n public SendCenterAiService(SendCenterAiBatchMapper mapper,\n SendCenterRecipientResolver recipientResolver,\n MailAccountMapper mailAccountMapper,\n SecretCryptoService secretCryptoService,\n MailSender mailSender,\n SendHistoryService sendHistoryService,\n AiChatClient aiChatClient,\n MailAiProperties properties,\n UserSettingsService userSettingsService,\n MailAccountConnectionResolver connectionResolver) {\n this.mapper = mapper;\n this.recipientResolver = recipientResolver;\n this.mailAccountMapper = mailAccountMapper;\n this.secretCryptoService = secretCryptoService;\n this.mailSender = mailSender;\n this.sendHistoryService = sendHistoryService;\n this.aiChatClient = aiChatClient;\n this.properties = properties;\n this.userSettingsService = userSettingsService;\n this.connectionResolver = connectionResolver;\n }\n\n @Transactional\n public SendCenterAiBatchResponse generate(CurrentUser currentUser, SendCenterAiGenerateRequest request) {\n String apiKeyOverride = resolvePersonalAiApiKey(currentUser);\n ensureAiConfigured(apiKeyOverride);\n mailAccountMapper.findByIdAndUserId(request.accountId(), currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"邮箱账号不存在\"));\n ResolvedSendCenterRecipients resolved = recipientResolver.resolve(\n currentUser.id(), request.profileIds(), request.groupIds(), request.manualRecipients());\n if (resolved.recipients().isEmpty()) {\n throw new IllegalArgumentException(\"请至少选择一个收件人\");\n }\n if (safe(request.userInstruction()).isBlank() && safe(request.baseSubject()).isBlank() && safe(request.baseTextBody()).isBlank()) {\n throw new IllegalArgumentException(\"请提供群发写作要求或基础草稿\");\n }\n\n SendCenterAiBatch batch = buildBatch(currentUser.id(), request, resolved.recipients().size());\n mapper.insertBatch(batch);\n Map itemByEmail = insertPendingItems(batch.getId(), resolved.recipients());\n try {\n AiBatchDraftResponse aiResponse = objectMapper.readValue(\n aiChatClient.completeJson(SYSTEM_PROMPT, buildBatchPrompt(request, resolved), apiKeyOverride),\n AiBatchDraftResponse.class\n );\n applyGeneratedItems(batch, itemByEmail, aiResponse.items());\n } catch (JsonProcessingException ex) {\n markAllFailed(batch, itemByEmail, \"AI 返回格式异常\");\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n }\n return get(currentUser, batch.getId());\n }\n\n public SendCenterAiBatchResponse get(CurrentUser currentUser, Long batchId) {\n SendCenterAiBatch batch = getOwnedBatch(currentUser, batchId);\n return toBatchResponse(batch, mapper.findItemsByBatchId(batch.getId()));\n }\n\n @Transactional\n public SendCenterAiItemResponse updateItem(CurrentUser currentUser, Long batchId, Long itemId, SendCenterAiUpdateItemRequest request) {\n getOwnedBatch(currentUser, batchId);\n SendCenterAiItem item = getOwnedItem(currentUser, batchId, itemId);\n item.setSubject(request.subject());\n item.setTextBody(request.textBody());\n item.setHtmlBody(request.htmlBody());\n item.setStatus(\"GENERATED\");\n item.setErrorMessage(null);\n item.setGeneratedAt(LocalDateTime.now());\n mapper.updateItem(item);\n refreshBatchStatus(batchId, currentUser.id());\n return toItemResponse(getOwnedItem(currentUser, batchId, itemId));\n }\n\n @Transactional\n public SendCenterAiItemResponse regenerateItem(CurrentUser currentUser, Long batchId, Long itemId, SendCenterAiRegenerateRequest request) {\n String apiKeyOverride = resolvePersonalAiApiKey(currentUser);\n ensureAiConfigured(apiKeyOverride);\n SendCenterAiBatch batch = getOwnedBatch(currentUser, batchId);\n SendCenterAiItem item = getOwnedItem(currentUser, batchId, itemId);\n try {\n AiBatchDraftResponse response = objectMapper.readValue(\n aiChatClient.completeJson(SYSTEM_PROMPT, buildRegeneratePrompt(batch, item, request), apiKeyOverride),\n AiBatchDraftResponse.class\n );\n AiBatchDraftItem generated = response.items() == null || response.items().isEmpty() ? null : response.items().get(0);\n if (generated == null) {\n applyFailure(item, request.feedback(), \"AI 未返回该联系人草稿\");\n } else {\n applyGenerated(item, generated, request.feedback());\n }\n mapper.updateItem(item);\n refreshBatchStatus(batchId, currentUser.id());\n return toItemResponse(getOwnedItem(currentUser, batchId, itemId));\n } catch (JsonProcessingException ex) {\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n }\n }\n\n @Transactional\n public MailBatchSendResponse sendPersonalized(CurrentUser currentUser, Long batchId, SendCenterPersonalizedSendRequest request) {\n SendCenterAiBatch batch = getOwnedBatch(currentUser, batchId);\n MailAccount account = mailAccountMapper.findByIdAndUserId(batch.getAccountId(), currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"邮箱账号不存在\"));\n String authSecret = resolveSendAuthSecret(account);\n List selectedItemIds = distinctIds(request == null ? null : request.itemIds());\n boolean skipHighRisk = request != null && Boolean.TRUE.equals(request.skipHighRisk());\n List sendItems = mapper.findItemsByBatchId(batchId).stream()\n .filter(item -> selectedItemIds.isEmpty() || selectedItemIds.contains(item.getId()))\n .filter(item -> \"GENERATED\".equals(item.getStatus()))\n .filter(item -> !skipHighRisk || !\"HIGH\".equals(item.getSafetyLevel()))\n .filter(item -> !safe(item.getSubject()).isBlank() && !safe(item.getTextBody()).isBlank())\n .toList();\n if (sendItems.isEmpty()) {\n throw new IllegalArgumentException(\"没有可发送的 AI 个性化草稿\");\n }\n\n List responses = new ArrayList<>();\n for (SendCenterAiItem item : sendItems) {\n try {\n MailSendResult result = mailSender.send(toSendCommand(account, authSecret, item));\n responses.add(new MailBatchSendItemResponse(item.getProfileId(), item.getDisplayName(), item.getEmailAddress(), item.getSource(), true, null, result.messageId(), result.sentAt()));\n } catch (RuntimeException ex) {\n responses.add(new MailBatchSendItemResponse(item.getProfileId(), item.getDisplayName(), item.getEmailAddress(), item.getSource(), false, ex.getMessage(), null, null));\n }\n }\n int successCount = (int) responses.stream().filter(MailBatchSendItemResponse::success).count();\n sendHistoryService.recordPersonalizedBatch(currentUser.id(), batch.getAccountId(), account.getEmailAddress(), \"AI 个性化群发\", responses);\n return new MailBatchSendResponse(responses.size(), successCount, responses.size() - successCount, responses);\n }\n\n private SendCenterAiBatch buildBatch(Long userId, SendCenterAiGenerateRequest request, int totalCount) {\n SendCenterAiBatch batch = new SendCenterAiBatch();\n batch.setUserId(userId);\n batch.setAccountId(request.accountId());\n batch.setProfileIds(writeJson(distinctIds(request.profileIds())));\n batch.setGroupIds(writeJson(distinctIds(request.groupIds())));\n batch.setManualRecipients(writeJson(normalizeManualRecipients(request.manualRecipients())));\n batch.setUserInstruction(request.userInstruction());\n batch.setBaseSubject(request.baseSubject());\n batch.setBaseTextBody(request.baseTextBody());\n batch.setModel(properties.getModel());\n batch.setStatus(\"GENERATING\");\n batch.setTotalCount(totalCount);\n batch.setGeneratedCount(0);\n batch.setFailedCount(0);\n return batch;\n }\n\n private Map insertPendingItems(Long batchId, List recipients) {\n Map itemByEmail = new LinkedHashMap<>();\n for (ResolvedRecipient recipient : recipients) {\n SendCenterAiItem item = new SendCenterAiItem();\n item.setBatchId(batchId);\n item.setProfileId(recipient.profileId());\n item.setDisplayName(recipient.displayName());\n item.setEmailAddress(recipient.emailAddress());\n item.setSource(recipient.source());\n item.setStatus(\"PENDING\");\n mapper.insertItem(item);\n itemByEmail.put(recipient.emailAddress(), item);\n }\n return itemByEmail;\n }\n\n private void applyGeneratedItems(SendCenterAiBatch batch, Map itemByEmail, List generatedItems) {\n Map generatedByEmail = new LinkedHashMap<>();\n for (AiBatchDraftItem generated : generatedItems == null ? List.of() : generatedItems) {\n String email = normalizeEmail(generated.emailAddress());\n if (!email.isBlank() && itemByEmail.containsKey(email)) {\n generatedByEmail.put(email, generated);\n }\n }\n for (Map.Entry entry : itemByEmail.entrySet()) {\n AiBatchDraftItem generated = generatedByEmail.get(entry.getKey());\n SendCenterAiItem item = entry.getValue();\n if (generated == null) {\n applyFailure(item, null, \"AI 未返回该联系人草稿\");\n } else if (safe(generated.subject()).isBlank() || safe(generated.textBody()).isBlank()) {\n applyFailure(item, null, safe(generated.errorMessage()).isBlank() ? \"AI 返回草稿缺少主题或正文\" : generated.errorMessage());\n } else {\n applyGenerated(item, generated, null);\n }\n mapper.updateItem(item);\n }\n refreshBatchStatus(batch.getId(), batch.getUserId());\n }\n\n private void markAllFailed(SendCenterAiBatch batch, Map itemByEmail, String errorMessage) {\n for (SendCenterAiItem item : itemByEmail.values()) {\n applyFailure(item, null, errorMessage);\n mapper.updateItem(item);\n }\n refreshBatchStatus(batch.getId(), batch.getUserId());\n }\n\n private void applyGenerated(SendCenterAiItem item, AiBatchDraftItem generated, String feedback) {\n item.setSubject(generated.subject());\n item.setTextBody(generated.textBody());\n item.setHtmlBody(generated.htmlBody());\n item.setStatus(\"GENERATED\");\n item.setSafetyLevel(safe(generated.safetyLevel()).isBlank() ? \"LOW\" : generated.safetyLevel());\n item.setSummary(generated.summary());\n item.setImprovementsJson(writeJson(generated.improvements() == null ? List.of() : generated.improvements()));\n item.setRisksJson(writeJson(generated.risks() == null ? List.of() : generated.risks()));\n item.setLastFeedback(feedback);\n item.setErrorMessage(null);\n item.setGeneratedAt(LocalDateTime.now());\n }\n\n private void applyFailure(SendCenterAiItem item, String feedback, String errorMessage) {\n item.setStatus(\"FAILED\");\n item.setLastFeedback(feedback);\n item.setErrorMessage(errorMessage);\n item.setGeneratedAt(LocalDateTime.now());\n }\n\n private void refreshBatchStatus(Long batchId, Long userId) {\n SendCenterAiBatch batch = mapper.findBatchByIdAndUserId(batchId, userId)\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 群发批次不存在\"));\n List items = mapper.findItemsByBatchId(batchId);\n int generatedCount = (int) items.stream().filter(item -> \"GENERATED\".equals(item.getStatus())).count();\n int failedCount = (int) items.stream().filter(item -> \"FAILED\".equals(item.getStatus())).count();\n batch.setGeneratedCount(generatedCount);\n batch.setFailedCount(failedCount);\n if (generatedCount == items.size()) {\n batch.setStatus(\"GENERATED\");\n } else if (generatedCount > 0) {\n batch.setStatus(\"PARTIAL_FAILED\");\n } else if (failedCount == items.size()) {\n batch.setStatus(\"FAILED\");\n } else {\n batch.setStatus(\"GENERATING\");\n }\n mapper.updateBatchStatus(batch);\n }\n\n private String buildBatchPrompt(SendCenterAiGenerateRequest request, ResolvedSendCenterRecipients resolved) throws JsonProcessingException {\n return truncatePrompt(\"\"\"\n 请为下面去重后的联系人分别生成个性化邮件,并严格输出 JSON:\n {\n \"items\": [\n {\n \"emailAddress\": \"必须原样返回联系人邮箱\",\n \"subject\": \"邮件主题\",\n \"textBody\": \"纯文本正文\",\n \"htmlBody\": null,\n \"safetyLevel\": \"LOW | MEDIUM | HIGH\",\n \"summary\": \"一句话说明生成策略\",\n \"improvements\": [\"相对基础稿的改进点\"],\n \"risks\": [\"发送前注意点\"],\n \"errorMessage\": null\n }\n ]\n }\n\n 群发写作要求:%s\n 基础主题:%s\n 基础正文:%s\n\n 群组上下文:\n %s\n\n 联系人列表:\n %s\n \"\"\".formatted(\n blankAsNone(request.userInstruction()),\n blankAsNone(request.baseSubject()),\n blankAsNone(request.baseTextBody()),\n objectMapper.writeValueAsString(resolved.groups().stream().map(this::groupContext).toList()),\n objectMapper.writeValueAsString(resolved.recipients().stream().map(this::recipientContext).toList())\n ));\n }\n\n private String buildRegeneratePrompt(SendCenterAiBatch batch, SendCenterAiItem item, SendCenterAiRegenerateRequest request) throws JsonProcessingException {\n return truncatePrompt(\"\"\"\n 请根据用户反馈,只为这一个联系人重新生成一封个性化邮件,严格输出相同 JSON 格式且 items 只包含一个元素。\n\n 原始群发写作要求:%s\n 基础主题:%s\n 基础正文:%s\n 联系人:%s\n 当前主题:%s\n 当前正文:%s\n 用户反馈:%s\n \"\"\".formatted(\n blankAsNone(batch.getUserInstruction()),\n blankAsNone(batch.getBaseSubject()),\n blankAsNone(batch.getBaseTextBody()),\n objectMapper.writeValueAsString(Map.of(\n \"emailAddress\", item.getEmailAddress(),\n \"displayName\", safe(item.getDisplayName()),\n \"source\", safe(item.getSource())\n )),\n blankAsNone(request.currentSubject() == null ? item.getSubject() : request.currentSubject()),\n blankAsNone(request.currentTextBody() == null ? item.getTextBody() : request.currentTextBody()),\n request.feedback()\n ));\n }\n\n private Map recipientContext(ResolvedRecipient recipient) {\n Map context = new LinkedHashMap<>();\n context.put(\"emailAddress\", recipient.emailAddress());\n context.put(\"displayName\", safe(recipient.displayName()));\n context.put(\"source\", recipient.source());\n context.put(\"role\", safe(recipient.role()));\n context.put(\"relationship\", safe(recipient.relationship()));\n context.put(\"personality\", safe(recipient.personality()));\n context.put(\"communicationPreference\", safe(recipient.communicationPreference()));\n context.put(\"notes\", safe(recipient.notes()));\n if (recipient.group() != null) {\n context.put(\"group\", groupContext(recipient.group()));\n }\n return context;\n }\n\n private Map groupContext(RecipientGroup group) {\n Map context = new LinkedHashMap<>();\n context.put(\"name\", group.getName());\n context.put(\"description\", safe(group.getDescription()));\n context.put(\"scenario\", safe(group.getScenario()));\n context.put(\"tonePreference\", safe(group.getTonePreference()));\n context.put(\"bulkSendGuidance\", safe(group.getBulkSendGuidance()));\n return context;\n }\n\n private MailSendCommand toSendCommand(MailAccount account, String authSecret, SendCenterAiItem item) {\n return new MailSendCommand(\n account.getProvider(),\n account.getSmtpHost(),\n account.getSmtpPort(),\n account.getSmtpSsl(),\n account.getSmtpStartTls(),\n account.getAuthUsername(),\n authSecret,\n connectionResolver.proxyConfig(account),\n account.getEmailAddress(),\n item.getEmailAddress(),\n item.getSubject(),\n item.getTextBody(),\n item.getHtmlBody()\n );\n }\n\n private SendCenterAiBatch getOwnedBatch(CurrentUser currentUser, Long batchId) {\n return mapper.findBatchByIdAndUserId(batchId, currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 群发批次不存在\"));\n }\n\n private SendCenterAiItem getOwnedItem(CurrentUser currentUser, Long batchId, Long itemId) {\n return mapper.findItemByIdAndBatchIdAndUserId(itemId, batchId, currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 群发联系人草稿不存在\"));\n }\n\n private SendCenterAiBatchResponse toBatchResponse(SendCenterAiBatch batch, List items) {\n return new SendCenterAiBatchResponse(\n batch.getId(),\n batch.getAccountId(),\n readLongList(batch.getProfileIds()),\n readLongList(batch.getGroupIds()),\n readStringList(batch.getManualRecipients()),\n batch.getUserInstruction(),\n batch.getBaseSubject(),\n batch.getBaseTextBody(),\n batch.getModel(),\n batch.getStatus(),\n batch.getTotalCount(),\n batch.getGeneratedCount(),\n batch.getFailedCount(),\n batch.getCreatedAt(),\n batch.getUpdatedAt(),\n items.stream().map(this::toItemResponse).toList()\n );\n }\n\n private SendCenterAiItemResponse toItemResponse(SendCenterAiItem item) {\n return new SendCenterAiItemResponse(\n item.getId(),\n item.getProfileId(),\n item.getDisplayName(),\n item.getEmailAddress(),\n item.getSource(),\n item.getSubject(),\n item.getTextBody(),\n item.getHtmlBody(),\n item.getStatus(),\n item.getSafetyLevel(),\n item.getSummary(),\n readStringList(item.getImprovementsJson()),\n readStringList(item.getRisksJson()),\n item.getLastFeedback(),\n item.getErrorMessage(),\n item.getGeneratedAt(),\n item.getCreatedAt(),\n item.getUpdatedAt()\n );\n }\n\n private void ensureAiConfigured(String apiKeyOverride) {\n if (!properties.isEnabled()) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未启用\");\n }\n if ((apiKeyOverride == null || apiKeyOverride.isBlank()) && (properties.getApiKey() == null || properties.getApiKey().isBlank())) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未配置,请先设置 DEEPSEEK_API_KEY 或个人 AI API Key\");\n }\n }\n\n private String resolvePersonalAiApiKey(CurrentUser currentUser) {\n return userSettingsService.resolveAiApiKey(currentUser.id());\n }\n\n private String resolveSendAuthSecret(MailAccount account) {\n if (!Boolean.TRUE.equals(account.getEnabled())) {\n throw new IllegalArgumentException(\"邮箱账号已禁用,无法发送邮件\");\n }\n String authSecret = secretCryptoService.decrypt(account.getAuthSecretEncrypted());\n if (account.getAuthUsername() == null || account.getAuthUsername().isBlank() || authSecret == null || authSecret.isBlank()) {\n throw new IllegalArgumentException(\"发送邮件需要邮箱账号和授权码\");\n }\n return authSecret;\n }\n\n private List distinctIds(List ids) {\n if (ids == null || ids.isEmpty()) return List.of();\n return ids.stream().filter(id -> id != null)\n .collect(LinkedHashSet::new, LinkedHashSet::add, LinkedHashSet::addAll)\n .stream().toList();\n }\n\n private List normalizeManualRecipients(List recipients) {\n if (recipients == null || recipients.isEmpty()) return List.of();\n return recipients.stream().map(this::normalizeEmail).filter(email -> !email.isBlank())\n .collect(LinkedHashSet::new, LinkedHashSet::add, LinkedHashSet::addAll)\n .stream().toList();\n }\n\n private String normalizeEmail(String email) {\n return email == null ? \"\" : email.trim().toLowerCase(Locale.ROOT);\n }\n\n private String truncatePrompt(String prompt) {\n int maxChars = Math.max(1000, properties.getMaxInputChars());\n return prompt.length() <= maxChars ? prompt : prompt.substring(0, maxChars);\n }\n\n private String blankAsNone(String value) {\n return safe(value).isBlank() ? \"无\" : value;\n }\n\n private String safe(Object value) {\n return value == null ? \"\" : value.toString();\n }\n\n private String writeJson(Object value) {\n try {\n return objectMapper.writeValueAsString(value);\n } catch (JsonProcessingException ex) {\n throw new IllegalStateException(\"AI 群发数据序列化失败\", ex);\n }\n }\n\n private List readLongList(String json) {\n if (json == null || json.isBlank()) return List.of();\n try {\n return objectMapper.readValue(json, LONG_LIST_TYPE);\n } catch (JsonProcessingException ex) {\n throw new IllegalStateException(\"AI 群发批次数据解析失败\", ex);\n }\n }\n\n private List readStringList(String json) {\n if (json == null || json.isBlank()) return List.of();\n try {\n return objectMapper.readValue(json, STRING_LIST_TYPE);\n } catch (JsonProcessingException ex) {\n throw new IllegalStateException(\"AI 群发条目数据解析失败\", ex);\n }\n }\n}\n"}, "90d0a51f501ee4e0ce67097b1e8d6e8da5f8d452": {"t": 1785751010.0586984, "n": 25796, "h": "24c3e539a991d9c8", "c": "package com.mailnest.sendcenter.ai;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.core.type.TypeReference;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.mailnest.common.ResourceNotFoundException;\nimport com.mailnest.mailaccount.MailAccount;\nimport com.mailnest.mailaccount.MailAccountMapper;\nimport com.mailnest.mailai.AiChatClient;\nimport com.mailnest.mailai.MailAiException;\nimport com.mailnest.mailai.MailAiProperties;\nimport com.mailnest.mailmessage.dto.MailBatchSendItemResponse;\nimport com.mailnest.mailmessage.dto.MailBatchSendResponse;\nimport com.mailnest.mailprotocol.MailSendCommand;\nimport com.mailnest.mailprotocol.MailSendResult;\nimport com.mailnest.mailprotocol.MailSender;\nimport com.mailnest.recipient.RecipientGroup;\nimport com.mailnest.security.CurrentUser;\nimport com.mailnest.security.secret.SecretCryptoService;\nimport com.mailnest.settings.UserSettingsService;\nimport com.mailnest.sendcenter.ai.SendCenterRecipientResolver.ResolvedRecipient;\nimport com.mailnest.sendcenter.ai.SendCenterRecipientResolver.ResolvedSendCenterRecipients;\nimport com.mailnest.sendcenter.ai.dto.AiBatchDraftItem;\nimport com.mailnest.sendcenter.ai.dto.AiBatchDraftResponse;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiBatchResponse;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiGenerateRequest;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiItemResponse;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiRegenerateRequest;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiUpdateItemRequest;\nimport com.mailnest.sendcenter.ai.dto.SendCenterPersonalizedSendRequest;\nimport com.mailnest.sendhistory.SendHistoryService;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.LocalDateTime;\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\n\n@Service\npublic class SendCenterAiService {\n private static final String SYSTEM_PROMPT = \"\"\"\n 你是 MailNest 的发信中心 AI 群发助手,负责为用户选定的每个收件人生成个性化中文邮件草稿。\n 你必须只输出合法 JSON,不要输出 Markdown,不要添加解释。\n 不要编造事实、日期、附件、承诺、历史关系或用户身份;信息不足时使用稳妥表达。\n 每个收件人必须只生成给该收件人的一封邮件,不能泄露其他收件人或群发名单。\n \"\"\";\n\n private static final TypeReference> LONG_LIST_TYPE = new TypeReference<>() {\n };\n private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {\n };\n\n private final SendCenterAiBatchMapper mapper;\n private final SendCenterRecipientResolver recipientResolver;\n private final MailAccountMapper mailAccountMapper;\n private final SecretCryptoService secretCryptoService;\n private final MailSender mailSender;\n private final SendHistoryService sendHistoryService;\n private final AiChatClient aiChatClient;\n private final MailAiProperties properties;\n private final UserSettingsService userSettingsService;\n private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();\n\n public SendCenterAiService(SendCenterAiBatchMapper mapper,\n SendCenterRecipientResolver recipientResolver,\n MailAccountMapper mailAccountMapper,\n SecretCryptoService secretCryptoService,\n MailSender mailSender,\n SendHistoryService sendHistoryService,\n AiChatClient aiChatClient,\n MailAiProperties properties,\n UserSettingsService userSettingsService) {\n this.mapper = mapper;\n this.recipientResolver = recipientResolver;\n this.mailAccountMapper = mailAccountMapper;\n this.secretCryptoService = secretCryptoService;\n this.mailSender = mailSender;\n this.sendHistoryService = sendHistoryService;\n this.aiChatClient = aiChatClient;\n this.properties = properties;\n this.userSettingsService = userSettingsService;\n }\n\n @Transactional\n public SendCenterAiBatchResponse generate(CurrentUser currentUser, SendCenterAiGenerateRequest request) {\n String apiKeyOverride = resolvePersonalAiApiKey(currentUser);\n ensureAiConfigured(apiKeyOverride);\n mailAccountMapper.findByIdAndUserId(request.accountId(), currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"邮箱账号不存在\"));\n ResolvedSendCenterRecipients resolved = recipientResolver.resolve(\n currentUser.id(), request.profileIds(), request.groupIds(), request.manualRecipients());\n if (resolved.recipients().isEmpty()) {\n throw new IllegalArgumentException(\"请至少选择一个收件人\");\n }\n if (safe(request.userInstruction()).isBlank() && safe(request.baseSubject()).isBlank() && safe(request.baseTextBody()).isBlank()) {\n throw new IllegalArgumentException(\"请提供群发写作要求或基础草稿\");\n }\n\n SendCenterAiBatch batch = buildBatch(currentUser.id(), request, resolved.recipients().size());\n mapper.insertBatch(batch);\n Map itemByEmail = insertPendingItems(batch.getId(), resolved.recipients());\n try {\n AiBatchDraftResponse aiResponse = objectMapper.readValue(\n aiChatClient.completeJson(SYSTEM_PROMPT, buildBatchPrompt(request, resolved), apiKeyOverride),\n AiBatchDraftResponse.class\n );\n applyGeneratedItems(batch, itemByEmail, aiResponse.items());\n } catch (JsonProcessingException ex) {\n markAllFailed(batch, itemByEmail, \"AI 返回格式异常\");\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n }\n return get(currentUser, batch.getId());\n }\n\n public SendCenterAiBatchResponse get(CurrentUser currentUser, Long batchId) {\n SendCenterAiBatch batch = getOwnedBatch(currentUser, batchId);\n return toBatchResponse(batch, mapper.findItemsByBatchId(batch.getId()));\n }\n\n @Transactional\n public SendCenterAiItemResponse updateItem(CurrentUser currentUser, Long batchId, Long itemId, SendCenterAiUpdateItemRequest request) {\n getOwnedBatch(currentUser, batchId);\n SendCenterAiItem item = getOwnedItem(currentUser, batchId, itemId);\n item.setSubject(request.subject());\n item.setTextBody(request.textBody());\n item.setHtmlBody(request.htmlBody());\n item.setStatus(\"GENERATED\");\n item.setErrorMessage(null);\n item.setGeneratedAt(LocalDateTime.now());\n mapper.updateItem(item);\n refreshBatchStatus(batchId, currentUser.id());\n return toItemResponse(getOwnedItem(currentUser, batchId, itemId));\n }\n\n @Transactional\n public SendCenterAiItemResponse regenerateItem(CurrentUser currentUser, Long batchId, Long itemId, SendCenterAiRegenerateRequest request) {\n String apiKeyOverride = resolvePersonalAiApiKey(currentUser);\n ensureAiConfigured(apiKeyOverride);\n SendCenterAiBatch batch = getOwnedBatch(currentUser, batchId);\n SendCenterAiItem item = getOwnedItem(currentUser, batchId, itemId);\n try {\n AiBatchDraftResponse response = objectMapper.readValue(\n aiChatClient.completeJson(SYSTEM_PROMPT, buildRegeneratePrompt(batch, item, request), apiKeyOverride),\n AiBatchDraftResponse.class\n );\n AiBatchDraftItem generated = response.items() == null || response.items().isEmpty() ? null : response.items().get(0);\n if (generated == null) {\n applyFailure(item, request.feedback(), \"AI 未返回该联系人草稿\");\n } else {\n applyGenerated(item, generated, request.feedback());\n }\n mapper.updateItem(item);\n refreshBatchStatus(batchId, currentUser.id());\n return toItemResponse(getOwnedItem(currentUser, batchId, itemId));\n } catch (JsonProcessingException ex) {\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n }\n }\n\n @Transactional\n public MailBatchSendResponse sendPersonalized(CurrentUser currentUser, Long batchId, SendCenterPersonalizedSendRequest request) {\n SendCenterAiBatch batch = getOwnedBatch(currentUser, batchId);\n MailAccount account = mailAccountMapper.findByIdAndUserId(batch.getAccountId(), currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"邮箱账号不存在\"));\n String authSecret = resolveSendAuthSecret(account);\n List selectedItemIds = distinctIds(request == null ? null : request.itemIds());\n boolean skipHighRisk = request != null && Boolean.TRUE.equals(request.skipHighRisk());\n List sendItems = mapper.findItemsByBatchId(batchId).stream()\n .filter(item -> selectedItemIds.isEmpty() || selectedItemIds.contains(item.getId()))\n .filter(item -> \"GENERATED\".equals(item.getStatus()))\n .filter(item -> !skipHighRisk || !\"HIGH\".equals(item.getSafetyLevel()))\n .filter(item -> !safe(item.getSubject()).isBlank() && !safe(item.getTextBody()).isBlank())\n .toList();\n if (sendItems.isEmpty()) {\n throw new IllegalArgumentException(\"没有可发送的 AI 个性化草稿\");\n }\n\n List responses = new ArrayList<>();\n for (SendCenterAiItem item : sendItems) {\n try {\n MailSendResult result = mailSender.send(toSendCommand(account, authSecret, item));\n responses.add(new MailBatchSendItemResponse(item.getProfileId(), item.getDisplayName(), item.getEmailAddress(), item.getSource(), true, null, result.messageId(), result.sentAt()));\n } catch (RuntimeException ex) {\n responses.add(new MailBatchSendItemResponse(item.getProfileId(), item.getDisplayName(), item.getEmailAddress(), item.getSource(), false, ex.getMessage(), null, null));\n }\n }\n int successCount = (int) responses.stream().filter(MailBatchSendItemResponse::success).count();\n sendHistoryService.recordPersonalizedBatch(currentUser.id(), batch.getAccountId(), account.getEmailAddress(), \"AI 个性化群发\", responses);\n return new MailBatchSendResponse(responses.size(), successCount, responses.size() - successCount, responses);\n }\n\n private SendCenterAiBatch buildBatch(Long userId, SendCenterAiGenerateRequest request, int totalCount) {\n SendCenterAiBatch batch = new SendCenterAiBatch();\n batch.setUserId(userId);\n batch.setAccountId(request.accountId());\n batch.setProfileIds(writeJson(distinctIds(request.profileIds())));\n batch.setGroupIds(writeJson(distinctIds(request.groupIds())));\n batch.setManualRecipients(writeJson(normalizeManualRecipients(request.manualRecipients())));\n batch.setUserInstruction(request.userInstruction());\n batch.setBaseSubject(request.baseSubject());\n batch.setBaseTextBody(request.baseTextBody());\n batch.setModel(properties.getModel());\n batch.setStatus(\"GENERATING\");\n batch.setTotalCount(totalCount);\n batch.setGeneratedCount(0);\n batch.setFailedCount(0);\n return batch;\n }\n\n private Map insertPendingItems(Long batchId, List recipients) {\n Map itemByEmail = new LinkedHashMap<>();\n for (ResolvedRecipient recipient : recipients) {\n SendCenterAiItem item = new SendCenterAiItem();\n item.setBatchId(batchId);\n item.setProfileId(recipient.profileId());\n item.setDisplayName(recipient.displayName());\n item.setEmailAddress(recipient.emailAddress());\n item.setSource(recipient.source());\n item.setStatus(\"PENDING\");\n mapper.insertItem(item);\n itemByEmail.put(recipient.emailAddress(), item);\n }\n return itemByEmail;\n }\n\n private void applyGeneratedItems(SendCenterAiBatch batch, Map itemByEmail, List generatedItems) {\n Map generatedByEmail = new LinkedHashMap<>();\n for (AiBatchDraftItem generated : generatedItems == null ? List.of() : generatedItems) {\n String email = normalizeEmail(generated.emailAddress());\n if (!email.isBlank() && itemByEmail.containsKey(email)) {\n generatedByEmail.put(email, generated);\n }\n }\n for (Map.Entry entry : itemByEmail.entrySet()) {\n AiBatchDraftItem generated = generatedByEmail.get(entry.getKey());\n SendCenterAiItem item = entry.getValue();\n if (generated == null) {\n applyFailure(item, null, \"AI 未返回该联系人草稿\");\n } else if (safe(generated.subject()).isBlank() || safe(generated.textBody()).isBlank()) {\n applyFailure(item, null, safe(generated.errorMessage()).isBlank() ? \"AI 返回草稿缺少主题或正文\" : generated.errorMessage());\n } else {\n applyGenerated(item, generated, null);\n }\n mapper.updateItem(item);\n }\n refreshBatchStatus(batch.getId(), batch.getUserId());\n }\n\n private void markAllFailed(SendCenterAiBatch batch, Map itemByEmail, String errorMessage) {\n for (SendCenterAiItem item : itemByEmail.values()) {\n applyFailure(item, null, errorMessage);\n mapper.updateItem(item);\n }\n refreshBatchStatus(batch.getId(), batch.getUserId());\n }\n\n private void applyGenerated(SendCenterAiItem item, AiBatchDraftItem generated, String feedback) {\n item.setSubject(generated.subject());\n item.setTextBody(generated.textBody());\n item.setHtmlBody(generated.htmlBody());\n item.setStatus(\"GENERATED\");\n item.setSafetyLevel(safe(generated.safetyLevel()).isBlank() ? \"LOW\" : generated.safetyLevel());\n item.setSummary(generated.summary());\n item.setImprovementsJson(writeJson(generated.improvements() == null ? List.of() : generated.improvements()));\n item.setRisksJson(writeJson(generated.risks() == null ? List.of() : generated.risks()));\n item.setLastFeedback(feedback);\n item.setErrorMessage(null);\n item.setGeneratedAt(LocalDateTime.now());\n }\n\n private void applyFailure(SendCenterAiItem item, String feedback, String errorMessage) {\n item.setStatus(\"FAILED\");\n item.setLastFeedback(feedback);\n item.setErrorMessage(errorMessage);\n item.setGeneratedAt(LocalDateTime.now());\n }\n\n private void refreshBatchStatus(Long batchId, Long userId) {\n SendCenterAiBatch batch = mapper.findBatchByIdAndUserId(batchId, userId)\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 群发批次不存在\"));\n List items = mapper.findItemsByBatchId(batchId);\n int generatedCount = (int) items.stream().filter(item -> \"GENERATED\".equals(item.getStatus())).count();\n int failedCount = (int) items.stream().filter(item -> \"FAILED\".equals(item.getStatus())).count();\n batch.setGeneratedCount(generatedCount);\n batch.setFailedCount(failedCount);\n if (generatedCount == items.size()) {\n batch.setStatus(\"GENERATED\");\n } else if (generatedCount > 0) {\n batch.setStatus(\"PARTIAL_FAILED\");\n } else if (failedCount == items.size()) {\n batch.setStatus(\"FAILED\");\n } else {\n batch.setStatus(\"GENERATING\");\n }\n mapper.updateBatchStatus(batch);\n }\n\n private String buildBatchPrompt(SendCenterAiGenerateRequest request, ResolvedSendCenterRecipients resolved) throws JsonProcessingException {\n return truncatePrompt(\"\"\"\n 请为下面去重后的联系人分别生成个性化邮件,并严格输出 JSON:\n {\n \"items\": [\n {\n \"emailAddress\": \"必须原样返回联系人邮箱\",\n \"subject\": \"邮件主题\",\n \"textBody\": \"纯文本正文\",\n \"htmlBody\": null,\n \"safetyLevel\": \"LOW | MEDIUM | HIGH\",\n \"summary\": \"一句话说明生成策略\",\n \"improvements\": [\"相对基础稿的改进点\"],\n \"risks\": [\"发送前注意点\"],\n \"errorMessage\": null\n }\n ]\n }\n\n 群发写作要求:%s\n 基础主题:%s\n 基础正文:%s\n\n 群组上下文:\n %s\n\n 联系人列表:\n %s\n \"\"\".formatted(\n blankAsNone(request.userInstruction()),\n blankAsNone(request.baseSubject()),\n blankAsNone(request.baseTextBody()),\n objectMapper.writeValueAsString(resolved.groups().stream().map(this::groupContext).toList()),\n objectMapper.writeValueAsString(resolved.recipients().stream().map(this::recipientContext).toList())\n ));\n }\n\n private String buildRegeneratePrompt(SendCenterAiBatch batch, SendCenterAiItem item, SendCenterAiRegenerateRequest request) throws JsonProcessingException {\n return truncatePrompt(\"\"\"\n 请根据用户反馈,只为这一个联系人重新生成一封个性化邮件,严格输出相同 JSON 格式且 items 只包含一个元素。\n\n 原始群发写作要求:%s\n 基础主题:%s\n 基础正文:%s\n 联系人:%s\n 当前主题:%s\n 当前正文:%s\n 用户反馈:%s\n \"\"\".formatted(\n blankAsNone(batch.getUserInstruction()),\n blankAsNone(batch.getBaseSubject()),\n blankAsNone(batch.getBaseTextBody()),\n objectMapper.writeValueAsString(Map.of(\n \"emailAddress\", item.getEmailAddress(),\n \"displayName\", safe(item.getDisplayName()),\n \"source\", safe(item.getSource())\n )),\n blankAsNone(request.currentSubject() == null ? item.getSubject() : request.currentSubject()),\n blankAsNone(request.currentTextBody() == null ? item.getTextBody() : request.currentTextBody()),\n request.feedback()\n ));\n }\n\n private Map recipientContext(ResolvedRecipient recipient) {\n Map context = new LinkedHashMap<>();\n context.put(\"emailAddress\", recipient.emailAddress());\n context.put(\"displayName\", safe(recipient.displayName()));\n context.put(\"source\", recipient.source());\n context.put(\"role\", safe(recipient.role()));\n context.put(\"relationship\", safe(recipient.relationship()));\n context.put(\"personality\", safe(recipient.personality()));\n context.put(\"communicationPreference\", safe(recipient.communicationPreference()));\n context.put(\"notes\", safe(recipient.notes()));\n if (recipient.group() != null) {\n context.put(\"group\", groupContext(recipient.group()));\n }\n return context;\n }\n\n private Map groupContext(RecipientGroup group) {\n Map context = new LinkedHashMap<>();\n context.put(\"name\", group.getName());\n context.put(\"description\", safe(group.getDescription()));\n context.put(\"scenario\", safe(group.getScenario()));\n context.put(\"tonePreference\", safe(group.getTonePreference()));\n context.put(\"bulkSendGuidance\", safe(group.getBulkSendGuidance()));\n return context;\n }\n\n private MailSendCommand toSendCommand(MailAccount account, String authSecret, SendCenterAiItem item) {\n return new MailSendCommand(\n account.getProvider(),\n account.getSmtpHost(),\n account.getSmtpPort(),\n account.getSmtpSsl(),\n account.getAuthUsername(),\n authSecret,\n account.getEmailAddress(),\n item.getEmailAddress(),\n item.getSubject(),\n item.getTextBody(),\n item.getHtmlBody()\n );\n }\n\n private SendCenterAiBatch getOwnedBatch(CurrentUser currentUser, Long batchId) {\n return mapper.findBatchByIdAndUserId(batchId, currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 群发批次不存在\"));\n }\n\n private SendCenterAiItem getOwnedItem(CurrentUser currentUser, Long batchId, Long itemId) {\n return mapper.findItemByIdAndBatchIdAndUserId(itemId, batchId, currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 群发联系人草稿不存在\"));\n }\n\n private SendCenterAiBatchResponse toBatchResponse(SendCenterAiBatch batch, List items) {\n return new SendCenterAiBatchResponse(\n batch.getId(),\n batch.getAccountId(),\n readLongList(batch.getProfileIds()),\n readLongList(batch.getGroupIds()),\n readStringList(batch.getManualRecipients()),\n batch.getUserInstruction(),\n batch.getBaseSubject(),\n batch.getBaseTextBody(),\n batch.getModel(),\n batch.getStatus(),\n batch.getTotalCount(),\n batch.getGeneratedCount(),\n batch.getFailedCount(),\n batch.getCreatedAt(),\n batch.getUpdatedAt(),\n items.stream().map(this::toItemResponse).toList()\n );\n }\n\n private SendCenterAiItemResponse toItemResponse(SendCenterAiItem item) {\n return new SendCenterAiItemResponse(\n item.getId(),\n item.getProfileId(),\n item.getDisplayName(),\n item.getEmailAddress(),\n item.getSource(),\n item.getSubject(),\n item.getTextBody(),\n item.getHtmlBody(),\n item.getStatus(),\n item.getSafetyLevel(),\n item.getSummary(),\n readStringList(item.getImprovementsJson()),\n readStringList(item.getRisksJson()),\n item.getLastFeedback(),\n item.getErrorMessage(),\n item.getGeneratedAt(),\n item.getCreatedAt(),\n item.getUpdatedAt()\n );\n }\n\n private void ensureAiConfigured(String apiKeyOverride) {\n if (!properties.isEnabled()) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未启用\");\n }\n if ((apiKeyOverride == null || apiKeyOverride.isBlank()) && (properties.getApiKey() == null || properties.getApiKey().isBlank())) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未配置,请先设置 DEEPSEEK_API_KEY 或个人 AI API Key\");\n }\n }\n\n private String resolvePersonalAiApiKey(CurrentUser currentUser) {\n return userSettingsService.resolveAiApiKey(currentUser.id());\n }\n\n private String resolveSendAuthSecret(MailAccount account) {\n if (!Boolean.TRUE.equals(account.getEnabled())) {\n throw new IllegalArgumentException(\"邮箱账号已禁用,无法发送邮件\");\n }\n String authSecret = secretCryptoService.decrypt(account.getAuthSecretEncrypted());\n if (account.getAuthUsername() == null || account.getAuthUsername().isBlank() || authSecret == null || authSecret.isBlank()) {\n throw new IllegalArgumentException(\"发送邮件需要邮箱账号和授权码\");\n }\n return authSecret;\n }\n\n private List distinctIds(List ids) {\n if (ids == null || ids.isEmpty()) return List.of();\n return ids.stream().filter(id -> id != null)\n .collect(LinkedHashSet::new, LinkedHashSet::add, LinkedHashSet::addAll)\n .stream().toList();\n }\n\n private List normalizeManualRecipients(List recipients) {\n if (recipients == null || recipients.isEmpty()) return List.of();\n return recipients.stream().map(this::normalizeEmail).filter(email -> !email.isBlank())\n .collect(LinkedHashSet::new, LinkedHashSet::add, LinkedHashSet::addAll)\n .stream().toList();\n }\n\n private String normalizeEmail(String email) {\n return email == null ? \"\" : email.trim().toLowerCase(Locale.ROOT);\n }\n\n private String truncatePrompt(String prompt) {\n int maxChars = Math.max(1000, properties.getMaxInputChars());\n return prompt.length() <= maxChars ? prompt : prompt.substring(0, maxChars);\n }\n\n private String blankAsNone(String value) {\n return safe(value).isBlank() ? \"无\" : value;\n }\n\n private String safe(Object value) {\n return value == null ? \"\" : value.toString();\n }\n\n private String writeJson(Object value) {\n try {\n return objectMapper.writeValueAsString(value);\n } catch (JsonProcessingException ex) {\n throw new IllegalStateException(\"AI 群发数据序列化失败\", ex);\n }\n }\n\n private List readLongList(String json) {\n if (json == null || json.isBlank()) return List.of();\n try {\n return objectMapper.readValue(json, LONG_LIST_TYPE);\n } catch (JsonProcessingException ex) {\n throw new IllegalStateException(\"AI 群发批次数据解析失败\", ex);\n }\n }\n\n private List readStringList(String json) {\n if (json == null || json.isBlank()) return List.of();\n try {\n return objectMapper.readValue(json, STRING_LIST_TYPE);\n } catch (JsonProcessingException ex) {\n throw new IllegalStateException(\"AI 群发条目数据解析失败\", ex);\n }\n }\n}\n"}, "45f68dd4245d7e768d44bb75b9a55d33a0650410": {"t": 1785751011.3164144, "n": 25118, "h": "33d8b865acd6beca", "c": "package com.mailnest.sendcenter.ai;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.core.type.TypeReference;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.mailnest.common.ResourceNotFoundException;\nimport com.mailnest.mailaccount.MailAccount;\nimport com.mailnest.mailaccount.MailAccountMapper;\nimport com.mailnest.mailai.AiChatClient;\nimport com.mailnest.mailai.MailAiException;\nimport com.mailnest.mailai.MailAiProperties;\nimport com.mailnest.mailmessage.dto.MailBatchSendItemResponse;\nimport com.mailnest.mailmessage.dto.MailBatchSendResponse;\nimport com.mailnest.mailprotocol.MailSendCommand;\nimport com.mailnest.mailprotocol.MailSendResult;\nimport com.mailnest.mailprotocol.MailSender;\nimport com.mailnest.recipient.RecipientGroup;\nimport com.mailnest.security.CurrentUser;\nimport com.mailnest.security.secret.SecretCryptoService;\nimport com.mailnest.sendcenter.ai.SendCenterRecipientResolver.ResolvedRecipient;\nimport com.mailnest.sendcenter.ai.SendCenterRecipientResolver.ResolvedSendCenterRecipients;\nimport com.mailnest.sendcenter.ai.dto.AiBatchDraftItem;\nimport com.mailnest.sendcenter.ai.dto.AiBatchDraftResponse;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiBatchResponse;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiGenerateRequest;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiItemResponse;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiRegenerateRequest;\nimport com.mailnest.sendcenter.ai.dto.SendCenterAiUpdateItemRequest;\nimport com.mailnest.sendcenter.ai.dto.SendCenterPersonalizedSendRequest;\nimport com.mailnest.sendhistory.SendHistoryService;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.LocalDateTime;\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\n\n@Service\npublic class SendCenterAiService {\n private static final String SYSTEM_PROMPT = \"\"\"\n 你是 MailNest 的发信中心 AI 群发助手,负责为用户选定的每个收件人生成个性化中文邮件草稿。\n 你必须只输出合法 JSON,不要输出 Markdown,不要添加解释。\n 不要编造事实、日期、附件、承诺、历史关系或用户身份;信息不足时使用稳妥表达。\n 每个收件人必须只生成给该收件人的一封邮件,不能泄露其他收件人或群发名单。\n \"\"\";\n\n private static final TypeReference> LONG_LIST_TYPE = new TypeReference<>() {\n };\n private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {\n };\n\n private final SendCenterAiBatchMapper mapper;\n private final SendCenterRecipientResolver recipientResolver;\n private final MailAccountMapper mailAccountMapper;\n private final SecretCryptoService secretCryptoService;\n private final MailSender mailSender;\n private final SendHistoryService sendHistoryService;\n private final AiChatClient aiChatClient;\n private final MailAiProperties properties;\n private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();\n\n public SendCenterAiService(SendCenterAiBatchMapper mapper,\n SendCenterRecipientResolver recipientResolver,\n MailAccountMapper mailAccountMapper,\n SecretCryptoService secretCryptoService,\n MailSender mailSender,\n SendHistoryService sendHistoryService,\n AiChatClient aiChatClient,\n MailAiProperties properties) {\n this.mapper = mapper;\n this.recipientResolver = recipientResolver;\n this.mailAccountMapper = mailAccountMapper;\n this.secretCryptoService = secretCryptoService;\n this.mailSender = mailSender;\n this.sendHistoryService = sendHistoryService;\n this.aiChatClient = aiChatClient;\n this.properties = properties;\n }\n\n @Transactional\n public SendCenterAiBatchResponse generate(CurrentUser currentUser, SendCenterAiGenerateRequest request) {\n ensureAiConfigured();\n mailAccountMapper.findByIdAndUserId(request.accountId(), currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"邮箱账号不存在\"));\n ResolvedSendCenterRecipients resolved = recipientResolver.resolve(\n currentUser.id(), request.profileIds(), request.groupIds(), request.manualRecipients());\n if (resolved.recipients().isEmpty()) {\n throw new IllegalArgumentException(\"请至少选择一个收件人\");\n }\n if (safe(request.userInstruction()).isBlank() && safe(request.baseSubject()).isBlank() && safe(request.baseTextBody()).isBlank()) {\n throw new IllegalArgumentException(\"请提供群发写作要求或基础草稿\");\n }\n\n SendCenterAiBatch batch = buildBatch(currentUser.id(), request, resolved.recipients().size());\n mapper.insertBatch(batch);\n Map itemByEmail = insertPendingItems(batch.getId(), resolved.recipients());\n try {\n AiBatchDraftResponse aiResponse = objectMapper.readValue(\n aiChatClient.completeJson(SYSTEM_PROMPT, buildBatchPrompt(request, resolved)),\n AiBatchDraftResponse.class\n );\n applyGeneratedItems(batch, itemByEmail, aiResponse.items());\n } catch (JsonProcessingException ex) {\n markAllFailed(batch, itemByEmail, \"AI 返回格式异常\");\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n }\n return get(currentUser, batch.getId());\n }\n\n public SendCenterAiBatchResponse get(CurrentUser currentUser, Long batchId) {\n SendCenterAiBatch batch = getOwnedBatch(currentUser, batchId);\n return toBatchResponse(batch, mapper.findItemsByBatchId(batch.getId()));\n }\n\n @Transactional\n public SendCenterAiItemResponse updateItem(CurrentUser currentUser, Long batchId, Long itemId, SendCenterAiUpdateItemRequest request) {\n getOwnedBatch(currentUser, batchId);\n SendCenterAiItem item = getOwnedItem(currentUser, batchId, itemId);\n item.setSubject(request.subject());\n item.setTextBody(request.textBody());\n item.setHtmlBody(request.htmlBody());\n item.setStatus(\"GENERATED\");\n item.setErrorMessage(null);\n item.setGeneratedAt(LocalDateTime.now());\n mapper.updateItem(item);\n refreshBatchStatus(batchId, currentUser.id());\n return toItemResponse(getOwnedItem(currentUser, batchId, itemId));\n }\n\n @Transactional\n public SendCenterAiItemResponse regenerateItem(CurrentUser currentUser, Long batchId, Long itemId, SendCenterAiRegenerateRequest request) {\n ensureAiConfigured();\n SendCenterAiBatch batch = getOwnedBatch(currentUser, batchId);\n SendCenterAiItem item = getOwnedItem(currentUser, batchId, itemId);\n try {\n AiBatchDraftResponse response = objectMapper.readValue(\n aiChatClient.completeJson(SYSTEM_PROMPT, buildRegeneratePrompt(batch, item, request)),\n AiBatchDraftResponse.class\n );\n AiBatchDraftItem generated = response.items() == null || response.items().isEmpty() ? null : response.items().get(0);\n if (generated == null) {\n applyFailure(item, request.feedback(), \"AI 未返回该联系人草稿\");\n } else {\n applyGenerated(item, generated, request.feedback());\n }\n mapper.updateItem(item);\n refreshBatchStatus(batchId, currentUser.id());\n return toItemResponse(getOwnedItem(currentUser, batchId, itemId));\n } catch (JsonProcessingException ex) {\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n }\n }\n\n @Transactional\n public MailBatchSendResponse sendPersonalized(CurrentUser currentUser, Long batchId, SendCenterPersonalizedSendRequest request) {\n SendCenterAiBatch batch = getOwnedBatch(currentUser, batchId);\n MailAccount account = mailAccountMapper.findByIdAndUserId(batch.getAccountId(), currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"邮箱账号不存在\"));\n String authSecret = resolveSendAuthSecret(account);\n List selectedItemIds = distinctIds(request == null ? null : request.itemIds());\n boolean skipHighRisk = request != null && Boolean.TRUE.equals(request.skipHighRisk());\n List sendItems = mapper.findItemsByBatchId(batchId).stream()\n .filter(item -> selectedItemIds.isEmpty() || selectedItemIds.contains(item.getId()))\n .filter(item -> \"GENERATED\".equals(item.getStatus()))\n .filter(item -> !skipHighRisk || !\"HIGH\".equals(item.getSafetyLevel()))\n .filter(item -> !safe(item.getSubject()).isBlank() && !safe(item.getTextBody()).isBlank())\n .toList();\n if (sendItems.isEmpty()) {\n throw new IllegalArgumentException(\"没有可发送的 AI 个性化草稿\");\n }\n\n List responses = new ArrayList<>();\n for (SendCenterAiItem item : sendItems) {\n try {\n MailSendResult result = mailSender.send(toSendCommand(account, authSecret, item));\n responses.add(new MailBatchSendItemResponse(item.getProfileId(), item.getDisplayName(), item.getEmailAddress(), item.getSource(), true, null, result.messageId(), result.sentAt()));\n } catch (RuntimeException ex) {\n responses.add(new MailBatchSendItemResponse(item.getProfileId(), item.getDisplayName(), item.getEmailAddress(), item.getSource(), false, ex.getMessage(), null, null));\n }\n }\n int successCount = (int) responses.stream().filter(MailBatchSendItemResponse::success).count();\n sendHistoryService.recordPersonalizedBatch(currentUser.id(), batch.getAccountId(), account.getEmailAddress(), \"AI 个性化群发\", responses);\n return new MailBatchSendResponse(responses.size(), successCount, responses.size() - successCount, responses);\n }\n\n private SendCenterAiBatch buildBatch(Long userId, SendCenterAiGenerateRequest request, int totalCount) {\n SendCenterAiBatch batch = new SendCenterAiBatch();\n batch.setUserId(userId);\n batch.setAccountId(request.accountId());\n batch.setProfileIds(writeJson(distinctIds(request.profileIds())));\n batch.setGroupIds(writeJson(distinctIds(request.groupIds())));\n batch.setManualRecipients(writeJson(normalizeManualRecipients(request.manualRecipients())));\n batch.setUserInstruction(request.userInstruction());\n batch.setBaseSubject(request.baseSubject());\n batch.setBaseTextBody(request.baseTextBody());\n batch.setModel(properties.getModel());\n batch.setStatus(\"GENERATING\");\n batch.setTotalCount(totalCount);\n batch.setGeneratedCount(0);\n batch.setFailedCount(0);\n return batch;\n }\n\n private Map insertPendingItems(Long batchId, List recipients) {\n Map itemByEmail = new LinkedHashMap<>();\n for (ResolvedRecipient recipient : recipients) {\n SendCenterAiItem item = new SendCenterAiItem();\n item.setBatchId(batchId);\n item.setProfileId(recipient.profileId());\n item.setDisplayName(recipient.displayName());\n item.setEmailAddress(recipient.emailAddress());\n item.setSource(recipient.source());\n item.setStatus(\"PENDING\");\n mapper.insertItem(item);\n itemByEmail.put(recipient.emailAddress(), item);\n }\n return itemByEmail;\n }\n\n private void applyGeneratedItems(SendCenterAiBatch batch, Map itemByEmail, List generatedItems) {\n Map generatedByEmail = new LinkedHashMap<>();\n for (AiBatchDraftItem generated : generatedItems == null ? List.of() : generatedItems) {\n String email = normalizeEmail(generated.emailAddress());\n if (!email.isBlank() && itemByEmail.containsKey(email)) {\n generatedByEmail.put(email, generated);\n }\n }\n for (Map.Entry entry : itemByEmail.entrySet()) {\n AiBatchDraftItem generated = generatedByEmail.get(entry.getKey());\n SendCenterAiItem item = entry.getValue();\n if (generated == null) {\n applyFailure(item, null, \"AI 未返回该联系人草稿\");\n } else if (safe(generated.subject()).isBlank() || safe(generated.textBody()).isBlank()) {\n applyFailure(item, null, safe(generated.errorMessage()).isBlank() ? \"AI 返回草稿缺少主题或正文\" : generated.errorMessage());\n } else {\n applyGenerated(item, generated, null);\n }\n mapper.updateItem(item);\n }\n refreshBatchStatus(batch.getId(), batch.getUserId());\n }\n\n private void markAllFailed(SendCenterAiBatch batch, Map itemByEmail, String errorMessage) {\n for (SendCenterAiItem item : itemByEmail.values()) {\n applyFailure(item, null, errorMessage);\n mapper.updateItem(item);\n }\n refreshBatchStatus(batch.getId(), batch.getUserId());\n }\n\n private void applyGenerated(SendCenterAiItem item, AiBatchDraftItem generated, String feedback) {\n item.setSubject(generated.subject());\n item.setTextBody(generated.textBody());\n item.setHtmlBody(generated.htmlBody());\n item.setStatus(\"GENERATED\");\n item.setSafetyLevel(safe(generated.safetyLevel()).isBlank() ? \"LOW\" : generated.safetyLevel());\n item.setSummary(generated.summary());\n item.setImprovementsJson(writeJson(generated.improvements() == null ? List.of() : generated.improvements()));\n item.setRisksJson(writeJson(generated.risks() == null ? List.of() : generated.risks()));\n item.setLastFeedback(feedback);\n item.setErrorMessage(null);\n item.setGeneratedAt(LocalDateTime.now());\n }\n\n private void applyFailure(SendCenterAiItem item, String feedback, String errorMessage) {\n item.setStatus(\"FAILED\");\n item.setLastFeedback(feedback);\n item.setErrorMessage(errorMessage);\n item.setGeneratedAt(LocalDateTime.now());\n }\n\n private void refreshBatchStatus(Long batchId, Long userId) {\n SendCenterAiBatch batch = mapper.findBatchByIdAndUserId(batchId, userId)\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 群发批次不存在\"));\n List items = mapper.findItemsByBatchId(batchId);\n int generatedCount = (int) items.stream().filter(item -> \"GENERATED\".equals(item.getStatus())).count();\n int failedCount = (int) items.stream().filter(item -> \"FAILED\".equals(item.getStatus())).count();\n batch.setGeneratedCount(generatedCount);\n batch.setFailedCount(failedCount);\n if (generatedCount == items.size()) {\n batch.setStatus(\"GENERATED\");\n } else if (generatedCount > 0) {\n batch.setStatus(\"PARTIAL_FAILED\");\n } else if (failedCount == items.size()) {\n batch.setStatus(\"FAILED\");\n } else {\n batch.setStatus(\"GENERATING\");\n }\n mapper.updateBatchStatus(batch);\n }\n\n private String buildBatchPrompt(SendCenterAiGenerateRequest request, ResolvedSendCenterRecipients resolved) throws JsonProcessingException {\n return truncatePrompt(\"\"\"\n 请为下面去重后的联系人分别生成个性化邮件,并严格输出 JSON:\n {\n \"items\": [\n {\n \"emailAddress\": \"必须原样返回联系人邮箱\",\n \"subject\": \"邮件主题\",\n \"textBody\": \"纯文本正文\",\n \"htmlBody\": null,\n \"safetyLevel\": \"LOW | MEDIUM | HIGH\",\n \"summary\": \"一句话说明生成策略\",\n \"improvements\": [\"相对基础稿的改进点\"],\n \"risks\": [\"发送前注意点\"],\n \"errorMessage\": null\n }\n ]\n }\n\n 群发写作要求:%s\n 基础主题:%s\n 基础正文:%s\n\n 群组上下文:\n %s\n\n 联系人列表:\n %s\n \"\"\".formatted(\n blankAsNone(request.userInstruction()),\n blankAsNone(request.baseSubject()),\n blankAsNone(request.baseTextBody()),\n objectMapper.writeValueAsString(resolved.groups().stream().map(this::groupContext).toList()),\n objectMapper.writeValueAsString(resolved.recipients().stream().map(this::recipientContext).toList())\n ));\n }\n\n private String buildRegeneratePrompt(SendCenterAiBatch batch, SendCenterAiItem item, SendCenterAiRegenerateRequest request) throws JsonProcessingException {\n return truncatePrompt(\"\"\"\n 请根据用户反馈,只为这一个联系人重新生成一封个性化邮件,严格输出相同 JSON 格式且 items 只包含一个元素。\n\n 原始群发写作要求:%s\n 基础主题:%s\n 基础正文:%s\n 联系人:%s\n 当前主题:%s\n 当前正文:%s\n 用户反馈:%s\n \"\"\".formatted(\n blankAsNone(batch.getUserInstruction()),\n blankAsNone(batch.getBaseSubject()),\n blankAsNone(batch.getBaseTextBody()),\n objectMapper.writeValueAsString(Map.of(\n \"emailAddress\", item.getEmailAddress(),\n \"displayName\", safe(item.getDisplayName()),\n \"source\", safe(item.getSource())\n )),\n blankAsNone(request.currentSubject() == null ? item.getSubject() : request.currentSubject()),\n blankAsNone(request.currentTextBody() == null ? item.getTextBody() : request.currentTextBody()),\n request.feedback()\n ));\n }\n\n private Map recipientContext(ResolvedRecipient recipient) {\n Map context = new LinkedHashMap<>();\n context.put(\"emailAddress\", recipient.emailAddress());\n context.put(\"displayName\", safe(recipient.displayName()));\n context.put(\"source\", recipient.source());\n context.put(\"role\", safe(recipient.role()));\n context.put(\"relationship\", safe(recipient.relationship()));\n context.put(\"personality\", safe(recipient.personality()));\n context.put(\"communicationPreference\", safe(recipient.communicationPreference()));\n context.put(\"notes\", safe(recipient.notes()));\n if (recipient.group() != null) {\n context.put(\"group\", groupContext(recipient.group()));\n }\n return context;\n }\n\n private Map groupContext(RecipientGroup group) {\n Map context = new LinkedHashMap<>();\n context.put(\"name\", group.getName());\n context.put(\"description\", safe(group.getDescription()));\n context.put(\"scenario\", safe(group.getScenario()));\n context.put(\"tonePreference\", safe(group.getTonePreference()));\n context.put(\"bulkSendGuidance\", safe(group.getBulkSendGuidance()));\n return context;\n }\n\n private MailSendCommand toSendCommand(MailAccount account, String authSecret, SendCenterAiItem item) {\n return new MailSendCommand(\n account.getProvider(),\n account.getSmtpHost(),\n account.getSmtpPort(),\n account.getSmtpSsl(),\n account.getAuthUsername(),\n authSecret,\n account.getEmailAddress(),\n item.getEmailAddress(),\n item.getSubject(),\n item.getTextBody(),\n item.getHtmlBody()\n );\n }\n\n private SendCenterAiBatch getOwnedBatch(CurrentUser currentUser, Long batchId) {\n return mapper.findBatchByIdAndUserId(batchId, currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 群发批次不存在\"));\n }\n\n private SendCenterAiItem getOwnedItem(CurrentUser currentUser, Long batchId, Long itemId) {\n return mapper.findItemByIdAndBatchIdAndUserId(itemId, batchId, currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 群发联系人草稿不存在\"));\n }\n\n private SendCenterAiBatchResponse toBatchResponse(SendCenterAiBatch batch, List items) {\n return new SendCenterAiBatchResponse(\n batch.getId(),\n batch.getAccountId(),\n readLongList(batch.getProfileIds()),\n readLongList(batch.getGroupIds()),\n readStringList(batch.getManualRecipients()),\n batch.getUserInstruction(),\n batch.getBaseSubject(),\n batch.getBaseTextBody(),\n batch.getModel(),\n batch.getStatus(),\n batch.getTotalCount(),\n batch.getGeneratedCount(),\n batch.getFailedCount(),\n batch.getCreatedAt(),\n batch.getUpdatedAt(),\n items.stream().map(this::toItemResponse).toList()\n );\n }\n\n private SendCenterAiItemResponse toItemResponse(SendCenterAiItem item) {\n return new SendCenterAiItemResponse(\n item.getId(),\n item.getProfileId(),\n item.getDisplayName(),\n item.getEmailAddress(),\n item.getSource(),\n item.getSubject(),\n item.getTextBody(),\n item.getHtmlBody(),\n item.getStatus(),\n item.getSafetyLevel(),\n item.getSummary(),\n readStringList(item.getImprovementsJson()),\n readStringList(item.getRisksJson()),\n item.getLastFeedback(),\n item.getErrorMessage(),\n item.getGeneratedAt(),\n item.getCreatedAt(),\n item.getUpdatedAt()\n );\n }\n\n private void ensureAiConfigured() {\n if (!properties.isEnabled()) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未启用\");\n }\n if (properties.getApiKey() == null || properties.getApiKey().isBlank()) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未配置,请先设置 DEEPSEEK_API_KEY\");\n }\n }\n\n private String resolveSendAuthSecret(MailAccount account) {\n if (!Boolean.TRUE.equals(account.getEnabled())) {\n throw new IllegalArgumentException(\"邮箱账号已禁用,无法发送邮件\");\n }\n String authSecret = secretCryptoService.decrypt(account.getAuthSecretEncrypted());\n if (account.getAuthUsername() == null || account.getAuthUsername().isBlank() || authSecret == null || authSecret.isBlank()) {\n throw new IllegalArgumentException(\"发送邮件需要邮箱账号和授权码\");\n }\n return authSecret;\n }\n\n private List distinctIds(List ids) {\n if (ids == null || ids.isEmpty()) return List.of();\n return ids.stream().filter(id -> id != null)\n .collect(LinkedHashSet::new, LinkedHashSet::add, LinkedHashSet::addAll)\n .stream().toList();\n }\n\n private List normalizeManualRecipients(List recipients) {\n if (recipients == null || recipients.isEmpty()) return List.of();\n return recipients.stream().map(this::normalizeEmail).filter(email -> !email.isBlank())\n .collect(LinkedHashSet::new, LinkedHashSet::add, LinkedHashSet::addAll)\n .stream().toList();\n }\n\n private String normalizeEmail(String email) {\n return email == null ? \"\" : email.trim().toLowerCase(Locale.ROOT);\n }\n\n private String truncatePrompt(String prompt) {\n int maxChars = Math.max(1000, properties.getMaxInputChars());\n return prompt.length() <= maxChars ? prompt : prompt.substring(0, maxChars);\n }\n\n private String blankAsNone(String value) {\n return safe(value).isBlank() ? \"无\" : value;\n }\n\n private String safe(Object value) {\n return value == null ? \"\" : value.toString();\n }\n\n private String writeJson(Object value) {\n try {\n return objectMapper.writeValueAsString(value);\n } catch (JsonProcessingException ex) {\n throw new IllegalStateException(\"AI 群发数据序列化失败\", ex);\n }\n }\n\n private List readLongList(String json) {\n if (json == null || json.isBlank()) return List.of();\n try {\n return objectMapper.readValue(json, LONG_LIST_TYPE);\n } catch (JsonProcessingException ex) {\n throw new IllegalStateException(\"AI 群发批次数据解析失败\", ex);\n }\n }\n\n private List readStringList(String json) {\n if (json == null || json.isBlank()) return List.of();\n try {\n return objectMapper.readValue(json, STRING_LIST_TYPE);\n } catch (JsonProcessingException ex) {\n throw new IllegalStateException(\"AI 群发条目数据解析失败\", ex);\n }\n }\n}\n"}}, "backend/src/test/java/com/mailnest/mailai/MailAiControllerTest.java": {"da822d85dd1cf24cf2b9238faaf3389aa93275d6": {"t": 1785745685.9374504, "n": 16969, "h": "cefe32cd2ef2e740", "c": "package com.mailnest.mailai;\n\nimport com.mailnest.mailmessage.MailMessage;\nimport com.mailnest.mailmessage.MailMessageMapper;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.boot.test.context.TestConfiguration;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Primary;\nimport org.springframework.test.web.servlet.MockMvc;\n\nimport java.time.LocalDateTime;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\n@SpringBootTest(properties = {\n \"mailnest.ai.enabled=true\",\n \"mailnest.ai.api-key=test-key\",\n \"mailnest.ai.model=test-model\",\n \"mailnest.ai.max-input-chars=120\"\n})\n@org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc\nclass MailAiControllerTest {\n\n @Autowired\n private MockMvc mockMvc;\n\n @Autowired\n private MailMessageMapper mailMessageMapper;\n\n @Autowired\n private RecordingAiChatClient aiChatClient;\n\n @Autowired\n private MailAiProperties mailAiProperties;\n\n @AfterEach\n void resetAi() {\n aiChatClient.reset();\n mailAiProperties.setEnabled(true);\n mailAiProperties.setApiKey(\"test-key\");\n }\n\n @Test\n void rejectAiAnalysisWithoutToken() throws Exception {\n mockMvc.perform(post(\"/api/mail-ai/accounts/1/messages/1/analysis\"))\n .andExpect(status().isUnauthorized());\n }\n\n @Test\n void analyzeMessageForOwner() throws Exception {\n String token = registerAndToken(\"ai-owner@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-owner@163.com\", \"AI Owner\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"项目方案确认\", \"请在周五前补充预算测算并同步修改版。授权码不应出现。\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk())\n .andExpect(jsonPath(\"$.emailType\").value(\"WORK\"))\n .andExpect(jsonPath(\"$.brief\").value(\"这封邮件要求在周五前补充预算测算。\"))\n .andExpect(jsonPath(\"$.keyPoints[0]\").value(\"需要补充预算测算\"))\n .andExpect(jsonPath(\"$.senderIntent.type\").value(\"REQUEST_ACTION\"))\n .andExpect(jsonPath(\"$.tone.type\").value(\"URGENT\"))\n .andExpect(jsonPath(\"$.actionItems[0].priority\").value(\"HIGH\"))\n .andExpect(jsonPath(\"$.suggestedResponse.draft\").value(\"收到,我会在周五前补充预算测算并同步修改版。\"))\n .andExpect(jsonPath(\"$.model\").value(\"test-model\"))\n .andExpect(jsonPath(\"$.analyzedAt\").exists());\n\n assertThat(aiChatClient.lastUserPrompt()).contains(\"项目方案确认\");\n assertThat(aiChatClient.lastUserPrompt()).contains(\"请在周五前补充预算测算\");\n assertThat(aiChatClient.lastUserPrompt()).doesNotContain(\"message-auth-code\");\n }\n\n @Test\n void usePersonalAiApiKeyForAnalysis() throws Exception {\n String token = registerAndToken(\"ai-personal-key@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-personal-key@163.com\", \"AI Personal Key\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"个人 Key 测试\", \"请用个人 Key 分析。授权码不应出现。\");\n\n mockMvc.perform(put(\"/api/settings/ai-api-key\")\n .header(\"Authorization\", \"Bearer \" + token)\n .contentType(\"application/json\")\n .content(\"{\\\"apiKey\\\":\\\"sk-user-personal-key\\\"}\"))\n .andExpect(status().isOk())\n .andExpect(jsonPath(\"$.aiApiKey.hasApiKey\").value(true));\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk());\n\n assertThat(aiChatClient.lastApiKeyOverride()).isEqualTo(\"sk-user-personal-key\");\n }\n\n @Test\n void returnCachedAnalysisWithoutCallingAiAgain() throws Exception {\n String token = registerAndToken(\"ai-cache@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-cache@163.com\", \"AI Cache\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"缓存测试\", \"请分析一次即可。\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk());\n aiChatClient.resetCallCount();\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk())\n .andExpect(jsonPath(\"$.brief\").value(\"这封邮件要求在周五前补充预算测算。\"));\n\n assertThat(aiChatClient.callCount()).isZero();\n }\n\n @Test\n void getExistingAnalysis() throws Exception {\n String token = registerAndToken(\"ai-get@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-get@163.com\", \"AI Get\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"读取缓存\", \"先生成再读取。\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk());\n\n mockMvc.perform(get(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk())\n .andExpect(jsonPath(\"$.emailType\").value(\"WORK\"));\n }\n\n @Test\n void generateSuggestedReplyWithUserInstruction() throws Exception {\n String token = registerAndToken(\"ai-reply@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-reply@163.com\", \"AI Reply\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"合作邀请\", \"邀请你参加周五会议。\");\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk());\n aiChatClient.setNextResponse(RecordingAiChatClient.replyResponse());\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/suggested-reply\")\n .header(\"Authorization\", \"Bearer \" + token)\n .contentType(\"application/json\")\n .content(\"{\\\"userInstruction\\\":\\\"语气委婉一点\\\"}\"))\n .andExpect(status().isOk())\n .andExpect(jsonPath(\"$.required\").value(true))\n .andExpect(jsonPath(\"$.draft\").value(\"谢谢邀请,我会确认安排后尽快回复。\"));\n\n assertThat(aiChatClient.lastUserPrompt()).contains(\"语气委婉一点\");\n }\n\n @Test\n void usersCannotAnalyzeEachOthersMessages() throws Exception {\n String ownerToken = registerAndToken(\"ai-private@mailnest.local\");\n String otherToken = registerAndToken(\"ai-other@mailnest.local\");\n String accountId = createMailAccount(ownerToken, \"ai-private@163.com\", \"AI Private\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"Private\", \"Private body\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + otherToken))\n .andExpect(status().isNotFound());\n }\n\n @Test\n void rejectMissingMessage() throws Exception {\n String token = registerAndToken(\"ai-missing@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-missing@163.com\", \"AI Missing\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/999999/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isNotFound())\n .andExpect(jsonPath(\"$.detail\").value(\"邮件不存在\"));\n }\n\n @Test\n void rejectInvalidAiJson() throws Exception {\n String token = registerAndToken(\"ai-invalid@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-invalid@163.com\", \"AI Invalid\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"Invalid\", \"Invalid body\");\n aiChatClient.setNextResponse(\"not json\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isBadGateway())\n .andExpect(jsonPath(\"$.detail\").value(\"AI 返回格式异常\"));\n }\n\n @Test\n void rejectDisabledAiService() throws Exception {\n String token = registerAndToken(\"ai-disabled@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-disabled@163.com\", \"AI Disabled\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"Disabled\", \"Disabled body\");\n mailAiProperties.setEnabled(false);\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isServiceUnavailable())\n .andExpect(jsonPath(\"$.detail\").value(\"AI 服务未启用\"));\n }\n\n @Test\n void rejectMissingApiKey() throws Exception {\n String token = registerAndToken(\"ai-no-key@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-no-key@163.com\", \"AI No Key\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"No Key\", \"No key body\");\n mailAiProperties.setApiKey(\"\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isServiceUnavailable())\n .andExpect(jsonPath(\"$.detail\").value(\"AI 服务未配置,请先设置 DEEPSEEK_API_KEY 或个人 AI API Key\"));\n }\n\n private String registerAndToken(String email) throws Exception {\n String response = mockMvc.perform(post(\"/api/auth/register\")\n .contentType(\"application/json\")\n .content(\"\"\"\n {\"email\":\"%s\",\"password\":\"password123\",\"displayName\":\"Test User\"}\n \"\"\".formatted(email)))\n .andExpect(status().isCreated())\n .andReturn()\n .getResponse()\n .getContentAsString();\n return response.replaceAll(\".*\\\\\\\"accessToken\\\\\\\":\\\\\\\"([^\\\\\\\"]+)\\\\\\\".*\", \"$1\");\n }\n\n private String createMailAccount(String token, String emailAddress, String displayName) throws Exception {\n String response = mockMvc.perform(post(\"/api/mail-accounts\")\n .header(\"Authorization\", \"Bearer \" + token)\n .contentType(\"application/json\")\n .content(\"\"\"\n {\n \"emailAddress\":\"%s\",\n \"displayName\":\"%s\",\n \"provider\":\"163\",\n \"authUsername\":\"%s\",\n \"authSecret\":\"message-auth-code\",\n \"enabled\":true\n }\n \"\"\".formatted(emailAddress, displayName, emailAddress)))\n .andExpect(status().isCreated())\n .andReturn()\n .getResponse()\n .getContentAsString();\n return extractJsonNumber(response, \"id\");\n }\n\n private MailMessage insertMessage(Long accountId, String subject, String body) {\n MailMessage mailMessage = new MailMessage();\n mailMessage.setAccountId(accountId);\n mailMessage.setFolderId(1L);\n mailMessage.setUid(System.nanoTime());\n mailMessage.setMessageId(\"\");\n mailMessage.setSubject(subject);\n mailMessage.setFromAddress(\"sender@example.com\");\n mailMessage.setToAddresses(\"owner@example.com\");\n mailMessage.setSentAt(LocalDateTime.of(2026, 5, 19, 9, 0));\n mailMessage.setReceivedAt(LocalDateTime.of(2026, 5, 19, 9, 1));\n mailMessage.setSnippet(body.length() > 50 ? body.substring(0, 50) : body);\n mailMessage.setTextBody(body);\n mailMessage.setHtmlBody(\"

\" + body + \"

\");\n mailMessage.setReadFlag(true);\n mailMessage.setHasAttachments(false);\n mailMessageMapper.insert(mailMessage);\n return mailMessage;\n }\n\n private String extractJsonNumber(String json, String fieldName) {\n return json.replaceAll(\".*\\\\\\\"\" + fieldName + \"\\\\\\\":([0-9]+).*\", \"$1\");\n }\n\n @TestConfiguration\n static class MailAiTestConfig {\n\n @Bean\n @Primary\n RecordingAiChatClient recordingAiChatClient() {\n return new RecordingAiChatClient();\n }\n }\n\n static class RecordingAiChatClient implements AiChatClient {\n\n private String nextResponse = validResponse();\n private String lastSystemPrompt;\n private String lastUserPrompt;\n private String lastApiKeyOverride;\n private int callCount;\n\n @Override\n public String completeJson(String systemPrompt, String userPrompt, String apiKeyOverride) {\n this.lastSystemPrompt = systemPrompt;\n this.lastUserPrompt = userPrompt;\n this.lastApiKeyOverride = apiKeyOverride;\n this.callCount++;\n return nextResponse;\n }\n\n void setNextResponse(String nextResponse) {\n this.nextResponse = nextResponse;\n }\n\n String lastUserPrompt() {\n return lastUserPrompt;\n }\n\n String lastApiKeyOverride() {\n return lastApiKeyOverride;\n }\n\n int callCount() {\n return callCount;\n }\n\n void resetCallCount() {\n this.callCount = 0;\n }\n\n void reset() {\n this.nextResponse = validResponse();\n this.lastSystemPrompt = null;\n this.lastUserPrompt = null;\n this.lastApiKeyOverride = null;\n this.callCount = 0;\n }\n\n private static String replyResponse() {\n return \"\"\"\n {\n \"required\": true,\n \"reason\": \"对方发出了会议邀请,需要回复确认。\",\n \"strategy\": \"先感谢邀请,再说明会确认安排。\",\n \"draft\": \"谢谢邀请,我会确认安排后尽快回复。\"\n }\n \"\"\";\n }\n\n private static String validResponse() {\n return \"\"\"\n {\n \"emailType\": \"WORK\",\n \"brief\": \"这封邮件要求在周五前补充预算测算。\",\n \"keyPoints\": [\"需要补充预算测算\", \"需要同步修改版\"],\n \"senderIntent\": {\n \"type\": \"REQUEST_ACTION\",\n \"label\": \"要求行动\",\n \"explanation\": \"发件人希望收件人完成修改并反馈。\"\n },\n \"tone\": {\n \"type\": \"URGENT\",\n \"label\": \"紧急\",\n \"confidence\": \"MEDIUM\",\n \"signals\": [\"出现周五前的时间要求\"],\n \"caution\": \"该判断仅基于当前邮件文本。\"\n },\n \"actionItems\": [\n {\n \"task\": \"补充预算测算\",\n \"deadline\": \"周五前\",\n \"priority\": \"HIGH\",\n \"evidence\": \"请在周五前补充预算测算\"\n }\n ],\n \"risks\": [],\n \"nextStep\": \"先确认任务范围,再按时提交修改版。\",\n \"suggestedResponse\": {\n \"required\": true,\n \"reason\": \"对方提出了明确任务,需要回复确认。\",\n \"strategy\": \"简洁确认任务和时间点。\",\n \"draft\": \"收到,我会在周五前补充预算测算并同步修改版。\"\n }\n }\n \"\"\";\n }\n }\n}\n"}, "90d0a51f501ee4e0ce67097b1e8d6e8da5f8d452": {"t": 1785751004.818053, "n": 16969, "h": "cefe32cd2ef2e740", "c": "package com.mailnest.mailai;\n\nimport com.mailnest.mailmessage.MailMessage;\nimport com.mailnest.mailmessage.MailMessageMapper;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.boot.test.context.TestConfiguration;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Primary;\nimport org.springframework.test.web.servlet.MockMvc;\n\nimport java.time.LocalDateTime;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\n@SpringBootTest(properties = {\n \"mailnest.ai.enabled=true\",\n \"mailnest.ai.api-key=test-key\",\n \"mailnest.ai.model=test-model\",\n \"mailnest.ai.max-input-chars=120\"\n})\n@org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc\nclass MailAiControllerTest {\n\n @Autowired\n private MockMvc mockMvc;\n\n @Autowired\n private MailMessageMapper mailMessageMapper;\n\n @Autowired\n private RecordingAiChatClient aiChatClient;\n\n @Autowired\n private MailAiProperties mailAiProperties;\n\n @AfterEach\n void resetAi() {\n aiChatClient.reset();\n mailAiProperties.setEnabled(true);\n mailAiProperties.setApiKey(\"test-key\");\n }\n\n @Test\n void rejectAiAnalysisWithoutToken() throws Exception {\n mockMvc.perform(post(\"/api/mail-ai/accounts/1/messages/1/analysis\"))\n .andExpect(status().isUnauthorized());\n }\n\n @Test\n void analyzeMessageForOwner() throws Exception {\n String token = registerAndToken(\"ai-owner@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-owner@163.com\", \"AI Owner\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"项目方案确认\", \"请在周五前补充预算测算并同步修改版。授权码不应出现。\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk())\n .andExpect(jsonPath(\"$.emailType\").value(\"WORK\"))\n .andExpect(jsonPath(\"$.brief\").value(\"这封邮件要求在周五前补充预算测算。\"))\n .andExpect(jsonPath(\"$.keyPoints[0]\").value(\"需要补充预算测算\"))\n .andExpect(jsonPath(\"$.senderIntent.type\").value(\"REQUEST_ACTION\"))\n .andExpect(jsonPath(\"$.tone.type\").value(\"URGENT\"))\n .andExpect(jsonPath(\"$.actionItems[0].priority\").value(\"HIGH\"))\n .andExpect(jsonPath(\"$.suggestedResponse.draft\").value(\"收到,我会在周五前补充预算测算并同步修改版。\"))\n .andExpect(jsonPath(\"$.model\").value(\"test-model\"))\n .andExpect(jsonPath(\"$.analyzedAt\").exists());\n\n assertThat(aiChatClient.lastUserPrompt()).contains(\"项目方案确认\");\n assertThat(aiChatClient.lastUserPrompt()).contains(\"请在周五前补充预算测算\");\n assertThat(aiChatClient.lastUserPrompt()).doesNotContain(\"message-auth-code\");\n }\n\n @Test\n void usePersonalAiApiKeyForAnalysis() throws Exception {\n String token = registerAndToken(\"ai-personal-key@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-personal-key@163.com\", \"AI Personal Key\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"个人 Key 测试\", \"请用个人 Key 分析。授权码不应出现。\");\n\n mockMvc.perform(put(\"/api/settings/ai-api-key\")\n .header(\"Authorization\", \"Bearer \" + token)\n .contentType(\"application/json\")\n .content(\"{\\\"apiKey\\\":\\\"sk-user-personal-key\\\"}\"))\n .andExpect(status().isOk())\n .andExpect(jsonPath(\"$.aiApiKey.hasApiKey\").value(true));\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk());\n\n assertThat(aiChatClient.lastApiKeyOverride()).isEqualTo(\"sk-user-personal-key\");\n }\n\n @Test\n void returnCachedAnalysisWithoutCallingAiAgain() throws Exception {\n String token = registerAndToken(\"ai-cache@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-cache@163.com\", \"AI Cache\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"缓存测试\", \"请分析一次即可。\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk());\n aiChatClient.resetCallCount();\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk())\n .andExpect(jsonPath(\"$.brief\").value(\"这封邮件要求在周五前补充预算测算。\"));\n\n assertThat(aiChatClient.callCount()).isZero();\n }\n\n @Test\n void getExistingAnalysis() throws Exception {\n String token = registerAndToken(\"ai-get@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-get@163.com\", \"AI Get\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"读取缓存\", \"先生成再读取。\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk());\n\n mockMvc.perform(get(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk())\n .andExpect(jsonPath(\"$.emailType\").value(\"WORK\"));\n }\n\n @Test\n void generateSuggestedReplyWithUserInstruction() throws Exception {\n String token = registerAndToken(\"ai-reply@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-reply@163.com\", \"AI Reply\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"合作邀请\", \"邀请你参加周五会议。\");\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk());\n aiChatClient.setNextResponse(RecordingAiChatClient.replyResponse());\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/suggested-reply\")\n .header(\"Authorization\", \"Bearer \" + token)\n .contentType(\"application/json\")\n .content(\"{\\\"userInstruction\\\":\\\"语气委婉一点\\\"}\"))\n .andExpect(status().isOk())\n .andExpect(jsonPath(\"$.required\").value(true))\n .andExpect(jsonPath(\"$.draft\").value(\"谢谢邀请,我会确认安排后尽快回复。\"));\n\n assertThat(aiChatClient.lastUserPrompt()).contains(\"语气委婉一点\");\n }\n\n @Test\n void usersCannotAnalyzeEachOthersMessages() throws Exception {\n String ownerToken = registerAndToken(\"ai-private@mailnest.local\");\n String otherToken = registerAndToken(\"ai-other@mailnest.local\");\n String accountId = createMailAccount(ownerToken, \"ai-private@163.com\", \"AI Private\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"Private\", \"Private body\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + otherToken))\n .andExpect(status().isNotFound());\n }\n\n @Test\n void rejectMissingMessage() throws Exception {\n String token = registerAndToken(\"ai-missing@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-missing@163.com\", \"AI Missing\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/999999/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isNotFound())\n .andExpect(jsonPath(\"$.detail\").value(\"邮件不存在\"));\n }\n\n @Test\n void rejectInvalidAiJson() throws Exception {\n String token = registerAndToken(\"ai-invalid@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-invalid@163.com\", \"AI Invalid\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"Invalid\", \"Invalid body\");\n aiChatClient.setNextResponse(\"not json\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isBadGateway())\n .andExpect(jsonPath(\"$.detail\").value(\"AI 返回格式异常\"));\n }\n\n @Test\n void rejectDisabledAiService() throws Exception {\n String token = registerAndToken(\"ai-disabled@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-disabled@163.com\", \"AI Disabled\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"Disabled\", \"Disabled body\");\n mailAiProperties.setEnabled(false);\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isServiceUnavailable())\n .andExpect(jsonPath(\"$.detail\").value(\"AI 服务未启用\"));\n }\n\n @Test\n void rejectMissingApiKey() throws Exception {\n String token = registerAndToken(\"ai-no-key@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-no-key@163.com\", \"AI No Key\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"No Key\", \"No key body\");\n mailAiProperties.setApiKey(\"\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isServiceUnavailable())\n .andExpect(jsonPath(\"$.detail\").value(\"AI 服务未配置,请先设置 DEEPSEEK_API_KEY 或个人 AI API Key\"));\n }\n\n private String registerAndToken(String email) throws Exception {\n String response = mockMvc.perform(post(\"/api/auth/register\")\n .contentType(\"application/json\")\n .content(\"\"\"\n {\"email\":\"%s\",\"password\":\"password123\",\"displayName\":\"Test User\"}\n \"\"\".formatted(email)))\n .andExpect(status().isCreated())\n .andReturn()\n .getResponse()\n .getContentAsString();\n return response.replaceAll(\".*\\\\\\\"accessToken\\\\\\\":\\\\\\\"([^\\\\\\\"]+)\\\\\\\".*\", \"$1\");\n }\n\n private String createMailAccount(String token, String emailAddress, String displayName) throws Exception {\n String response = mockMvc.perform(post(\"/api/mail-accounts\")\n .header(\"Authorization\", \"Bearer \" + token)\n .contentType(\"application/json\")\n .content(\"\"\"\n {\n \"emailAddress\":\"%s\",\n \"displayName\":\"%s\",\n \"provider\":\"163\",\n \"authUsername\":\"%s\",\n \"authSecret\":\"message-auth-code\",\n \"enabled\":true\n }\n \"\"\".formatted(emailAddress, displayName, emailAddress)))\n .andExpect(status().isCreated())\n .andReturn()\n .getResponse()\n .getContentAsString();\n return extractJsonNumber(response, \"id\");\n }\n\n private MailMessage insertMessage(Long accountId, String subject, String body) {\n MailMessage mailMessage = new MailMessage();\n mailMessage.setAccountId(accountId);\n mailMessage.setFolderId(1L);\n mailMessage.setUid(System.nanoTime());\n mailMessage.setMessageId(\"\");\n mailMessage.setSubject(subject);\n mailMessage.setFromAddress(\"sender@example.com\");\n mailMessage.setToAddresses(\"owner@example.com\");\n mailMessage.setSentAt(LocalDateTime.of(2026, 5, 19, 9, 0));\n mailMessage.setReceivedAt(LocalDateTime.of(2026, 5, 19, 9, 1));\n mailMessage.setSnippet(body.length() > 50 ? body.substring(0, 50) : body);\n mailMessage.setTextBody(body);\n mailMessage.setHtmlBody(\"

\" + body + \"

\");\n mailMessage.setReadFlag(true);\n mailMessage.setHasAttachments(false);\n mailMessageMapper.insert(mailMessage);\n return mailMessage;\n }\n\n private String extractJsonNumber(String json, String fieldName) {\n return json.replaceAll(\".*\\\\\\\"\" + fieldName + \"\\\\\\\":([0-9]+).*\", \"$1\");\n }\n\n @TestConfiguration\n static class MailAiTestConfig {\n\n @Bean\n @Primary\n RecordingAiChatClient recordingAiChatClient() {\n return new RecordingAiChatClient();\n }\n }\n\n static class RecordingAiChatClient implements AiChatClient {\n\n private String nextResponse = validResponse();\n private String lastSystemPrompt;\n private String lastUserPrompt;\n private String lastApiKeyOverride;\n private int callCount;\n\n @Override\n public String completeJson(String systemPrompt, String userPrompt, String apiKeyOverride) {\n this.lastSystemPrompt = systemPrompt;\n this.lastUserPrompt = userPrompt;\n this.lastApiKeyOverride = apiKeyOverride;\n this.callCount++;\n return nextResponse;\n }\n\n void setNextResponse(String nextResponse) {\n this.nextResponse = nextResponse;\n }\n\n String lastUserPrompt() {\n return lastUserPrompt;\n }\n\n String lastApiKeyOverride() {\n return lastApiKeyOverride;\n }\n\n int callCount() {\n return callCount;\n }\n\n void resetCallCount() {\n this.callCount = 0;\n }\n\n void reset() {\n this.nextResponse = validResponse();\n this.lastSystemPrompt = null;\n this.lastUserPrompt = null;\n this.lastApiKeyOverride = null;\n this.callCount = 0;\n }\n\n private static String replyResponse() {\n return \"\"\"\n {\n \"required\": true,\n \"reason\": \"对方发出了会议邀请,需要回复确认。\",\n \"strategy\": \"先感谢邀请,再说明会确认安排。\",\n \"draft\": \"谢谢邀请,我会确认安排后尽快回复。\"\n }\n \"\"\";\n }\n\n private static String validResponse() {\n return \"\"\"\n {\n \"emailType\": \"WORK\",\n \"brief\": \"这封邮件要求在周五前补充预算测算。\",\n \"keyPoints\": [\"需要补充预算测算\", \"需要同步修改版\"],\n \"senderIntent\": {\n \"type\": \"REQUEST_ACTION\",\n \"label\": \"要求行动\",\n \"explanation\": \"发件人希望收件人完成修改并反馈。\"\n },\n \"tone\": {\n \"type\": \"URGENT\",\n \"label\": \"紧急\",\n \"confidence\": \"MEDIUM\",\n \"signals\": [\"出现周五前的时间要求\"],\n \"caution\": \"该判断仅基于当前邮件文本。\"\n },\n \"actionItems\": [\n {\n \"task\": \"补充预算测算\",\n \"deadline\": \"周五前\",\n \"priority\": \"HIGH\",\n \"evidence\": \"请在周五前补充预算测算\"\n }\n ],\n \"risks\": [],\n \"nextStep\": \"先确认任务范围,再按时提交修改版。\",\n \"suggestedResponse\": {\n \"required\": true,\n \"reason\": \"对方提出了明确任务,需要回复确认。\",\n \"strategy\": \"简洁确认任务和时间点。\",\n \"draft\": \"收到,我会在周五前补充预算测算并同步修改版。\"\n }\n }\n \"\"\";\n }\n }\n}\n"}, "da949067750b48e336bd71fe108866f39356b964": {"t": 1785751005.9547935, "n": 15554, "h": "683a48fbdb5f22e5", "c": "package com.mailnest.mailai;\n\nimport com.mailnest.mailmessage.MailMessage;\nimport com.mailnest.mailmessage.MailMessageMapper;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.boot.test.context.TestConfiguration;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Primary;\nimport org.springframework.test.web.servlet.MockMvc;\n\nimport java.time.LocalDateTime;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\n@SpringBootTest(properties = {\n \"mailnest.ai.enabled=true\",\n \"mailnest.ai.api-key=test-key\",\n \"mailnest.ai.model=test-model\",\n \"mailnest.ai.max-input-chars=120\"\n})\n@org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc\nclass MailAiControllerTest {\n\n @Autowired\n private MockMvc mockMvc;\n\n @Autowired\n private MailMessageMapper mailMessageMapper;\n\n @Autowired\n private RecordingAiChatClient aiChatClient;\n\n @Autowired\n private MailAiProperties mailAiProperties;\n\n @AfterEach\n void resetAi() {\n aiChatClient.reset();\n mailAiProperties.setEnabled(true);\n mailAiProperties.setApiKey(\"test-key\");\n }\n\n @Test\n void rejectAiAnalysisWithoutToken() throws Exception {\n mockMvc.perform(post(\"/api/mail-ai/accounts/1/messages/1/analysis\"))\n .andExpect(status().isUnauthorized());\n }\n\n @Test\n void analyzeMessageForOwner() throws Exception {\n String token = registerAndToken(\"ai-owner@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-owner@163.com\", \"AI Owner\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"项目方案确认\", \"请在周五前补充预算测算并同步修改版。授权码不应出现。\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk())\n .andExpect(jsonPath(\"$.emailType\").value(\"WORK\"))\n .andExpect(jsonPath(\"$.brief\").value(\"这封邮件要求在周五前补充预算测算。\"))\n .andExpect(jsonPath(\"$.keyPoints[0]\").value(\"需要补充预算测算\"))\n .andExpect(jsonPath(\"$.senderIntent.type\").value(\"REQUEST_ACTION\"))\n .andExpect(jsonPath(\"$.tone.type\").value(\"URGENT\"))\n .andExpect(jsonPath(\"$.actionItems[0].priority\").value(\"HIGH\"))\n .andExpect(jsonPath(\"$.suggestedResponse.draft\").value(\"收到,我会在周五前补充预算测算并同步修改版。\"))\n .andExpect(jsonPath(\"$.model\").value(\"test-model\"))\n .andExpect(jsonPath(\"$.analyzedAt\").exists());\n\n assertThat(aiChatClient.lastUserPrompt()).contains(\"项目方案确认\");\n assertThat(aiChatClient.lastUserPrompt()).contains(\"请在周五前补充预算测算\");\n assertThat(aiChatClient.lastUserPrompt()).doesNotContain(\"message-auth-code\");\n }\n\n @Test\n void returnCachedAnalysisWithoutCallingAiAgain() throws Exception {\n String token = registerAndToken(\"ai-cache@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-cache@163.com\", \"AI Cache\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"缓存测试\", \"请分析一次即可。\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk());\n aiChatClient.resetCallCount();\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk())\n .andExpect(jsonPath(\"$.brief\").value(\"这封邮件要求在周五前补充预算测算。\"));\n\n assertThat(aiChatClient.callCount()).isZero();\n }\n\n @Test\n void getExistingAnalysis() throws Exception {\n String token = registerAndToken(\"ai-get@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-get@163.com\", \"AI Get\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"读取缓存\", \"先生成再读取。\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk());\n\n mockMvc.perform(get(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk())\n .andExpect(jsonPath(\"$.emailType\").value(\"WORK\"));\n }\n\n @Test\n void generateSuggestedReplyWithUserInstruction() throws Exception {\n String token = registerAndToken(\"ai-reply@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-reply@163.com\", \"AI Reply\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"合作邀请\", \"邀请你参加周五会议。\");\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk());\n aiChatClient.setNextResponse(RecordingAiChatClient.replyResponse());\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/suggested-reply\")\n .header(\"Authorization\", \"Bearer \" + token)\n .contentType(\"application/json\")\n .content(\"{\\\"userInstruction\\\":\\\"语气委婉一点\\\"}\"))\n .andExpect(status().isOk())\n .andExpect(jsonPath(\"$.required\").value(true))\n .andExpect(jsonPath(\"$.draft\").value(\"谢谢邀请,我会确认安排后尽快回复。\"));\n\n assertThat(aiChatClient.lastUserPrompt()).contains(\"语气委婉一点\");\n }\n\n @Test\n void usersCannotAnalyzeEachOthersMessages() throws Exception {\n String ownerToken = registerAndToken(\"ai-private@mailnest.local\");\n String otherToken = registerAndToken(\"ai-other@mailnest.local\");\n String accountId = createMailAccount(ownerToken, \"ai-private@163.com\", \"AI Private\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"Private\", \"Private body\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + otherToken))\n .andExpect(status().isNotFound());\n }\n\n @Test\n void rejectMissingMessage() throws Exception {\n String token = registerAndToken(\"ai-missing@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-missing@163.com\", \"AI Missing\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/999999/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isNotFound())\n .andExpect(jsonPath(\"$.detail\").value(\"邮件不存在\"));\n }\n\n @Test\n void rejectInvalidAiJson() throws Exception {\n String token = registerAndToken(\"ai-invalid@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-invalid@163.com\", \"AI Invalid\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"Invalid\", \"Invalid body\");\n aiChatClient.setNextResponse(\"not json\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isBadGateway())\n .andExpect(jsonPath(\"$.detail\").value(\"AI 返回格式异常\"));\n }\n\n @Test\n void rejectDisabledAiService() throws Exception {\n String token = registerAndToken(\"ai-disabled@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-disabled@163.com\", \"AI Disabled\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"Disabled\", \"Disabled body\");\n mailAiProperties.setEnabled(false);\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isServiceUnavailable())\n .andExpect(jsonPath(\"$.detail\").value(\"AI 服务未启用\"));\n }\n\n @Test\n void rejectMissingApiKey() throws Exception {\n String token = registerAndToken(\"ai-no-key@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-no-key@163.com\", \"AI No Key\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"No Key\", \"No key body\");\n mailAiProperties.setApiKey(\"\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isServiceUnavailable())\n .andExpect(jsonPath(\"$.detail\").value(\"AI 服务未配置,请先设置 DEEPSEEK_API_KEY\"));\n }\n\n private String registerAndToken(String email) throws Exception {\n String response = mockMvc.perform(post(\"/api/auth/register\")\n .contentType(\"application/json\")\n .content(\"\"\"\n {\"email\":\"%s\",\"password\":\"password123\",\"displayName\":\"Test User\"}\n \"\"\".formatted(email)))\n .andExpect(status().isCreated())\n .andReturn()\n .getResponse()\n .getContentAsString();\n return response.replaceAll(\".*\\\\\\\"accessToken\\\\\\\":\\\\\\\"([^\\\\\\\"]+)\\\\\\\".*\", \"$1\");\n }\n\n private String createMailAccount(String token, String emailAddress, String displayName) throws Exception {\n String response = mockMvc.perform(post(\"/api/mail-accounts\")\n .header(\"Authorization\", \"Bearer \" + token)\n .contentType(\"application/json\")\n .content(\"\"\"\n {\n \"emailAddress\":\"%s\",\n \"displayName\":\"%s\",\n \"provider\":\"163\",\n \"authUsername\":\"%s\",\n \"authSecret\":\"message-auth-code\",\n \"enabled\":true\n }\n \"\"\".formatted(emailAddress, displayName, emailAddress)))\n .andExpect(status().isCreated())\n .andReturn()\n .getResponse()\n .getContentAsString();\n return extractJsonNumber(response, \"id\");\n }\n\n private MailMessage insertMessage(Long accountId, String subject, String body) {\n MailMessage mailMessage = new MailMessage();\n mailMessage.setAccountId(accountId);\n mailMessage.setFolderId(1L);\n mailMessage.setUid(System.nanoTime());\n mailMessage.setMessageId(\"\");\n mailMessage.setSubject(subject);\n mailMessage.setFromAddress(\"sender@example.com\");\n mailMessage.setToAddresses(\"owner@example.com\");\n mailMessage.setSentAt(LocalDateTime.of(2026, 5, 19, 9, 0));\n mailMessage.setReceivedAt(LocalDateTime.of(2026, 5, 19, 9, 1));\n mailMessage.setSnippet(body.length() > 50 ? body.substring(0, 50) : body);\n mailMessage.setTextBody(body);\n mailMessage.setHtmlBody(\"

\" + body + \"

\");\n mailMessage.setReadFlag(true);\n mailMessage.setHasAttachments(false);\n mailMessageMapper.insert(mailMessage);\n return mailMessage;\n }\n\n private String extractJsonNumber(String json, String fieldName) {\n return json.replaceAll(\".*\\\\\\\"\" + fieldName + \"\\\\\\\":([0-9]+).*\", \"$1\");\n }\n\n @TestConfiguration\n static class MailAiTestConfig {\n\n @Bean\n @Primary\n RecordingAiChatClient recordingAiChatClient() {\n return new RecordingAiChatClient();\n }\n }\n\n static class RecordingAiChatClient implements AiChatClient {\n\n private String nextResponse = validResponse();\n private String lastSystemPrompt;\n private String lastUserPrompt;\n private int callCount;\n\n @Override\n public String completeJson(String systemPrompt, String userPrompt) {\n this.lastSystemPrompt = systemPrompt;\n this.lastUserPrompt = userPrompt;\n this.callCount++;\n return nextResponse;\n }\n\n void setNextResponse(String nextResponse) {\n this.nextResponse = nextResponse;\n }\n\n String lastUserPrompt() {\n return lastUserPrompt;\n }\n\n int callCount() {\n return callCount;\n }\n\n void resetCallCount() {\n this.callCount = 0;\n }\n\n void reset() {\n this.nextResponse = validResponse();\n this.lastSystemPrompt = null;\n this.lastUserPrompt = null;\n this.callCount = 0;\n }\n\n private static String replyResponse() {\n return \"\"\"\n {\n \"required\": true,\n \"reason\": \"对方发出了会议邀请,需要回复确认。\",\n \"strategy\": \"先感谢邀请,再说明会确认安排。\",\n \"draft\": \"谢谢邀请,我会确认安排后尽快回复。\"\n }\n \"\"\";\n }\n\n private static String validResponse() {\n return \"\"\"\n {\n \"emailType\": \"WORK\",\n \"brief\": \"这封邮件要求在周五前补充预算测算。\",\n \"keyPoints\": [\"需要补充预算测算\", \"需要同步修改版\"],\n \"senderIntent\": {\n \"type\": \"REQUEST_ACTION\",\n \"label\": \"要求行动\",\n \"explanation\": \"发件人希望收件人完成修改并反馈。\"\n },\n \"tone\": {\n \"type\": \"URGENT\",\n \"label\": \"紧急\",\n \"confidence\": \"MEDIUM\",\n \"signals\": [\"出现周五前的时间要求\"],\n \"caution\": \"该判断仅基于当前邮件文本。\"\n },\n \"actionItems\": [\n {\n \"task\": \"补充预算测算\",\n \"deadline\": \"周五前\",\n \"priority\": \"HIGH\",\n \"evidence\": \"请在周五前补充预算测算\"\n }\n ],\n \"risks\": [],\n \"nextStep\": \"先确认任务范围,再按时提交修改版。\",\n \"suggestedResponse\": {\n \"required\": true,\n \"reason\": \"对方提出了明确任务,需要回复确认。\",\n \"strategy\": \"简洁确认任务和时间点。\",\n \"draft\": \"收到,我会在周五前补充预算测算并同步修改版。\"\n }\n }\n \"\"\";\n }\n }\n}\n"}, "a38a9a666ca7e8f251970a2a82f7e83d7832828c": {"t": 1785751007.0787344, "n": 11755, "h": "8562015e72965265", "c": "package com.mailnest.mailai;\n\nimport com.mailnest.mailmessage.MailMessage;\nimport com.mailnest.mailmessage.MailMessageMapper;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.boot.test.context.TestConfiguration;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Primary;\nimport org.springframework.test.web.servlet.MockMvc;\n\nimport java.time.LocalDateTime;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\n@SpringBootTest(properties = {\n \"mailnest.ai.enabled=true\",\n \"mailnest.ai.api-key=test-key\",\n \"mailnest.ai.model=test-model\",\n \"mailnest.ai.max-input-chars=120\"\n})\n@org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc\nclass MailAiControllerTest {\n\n @Autowired\n private MockMvc mockMvc;\n\n @Autowired\n private MailMessageMapper mailMessageMapper;\n\n @Autowired\n private RecordingAiChatClient aiChatClient;\n\n @Autowired\n private MailAiProperties mailAiProperties;\n\n @AfterEach\n void resetAi() {\n aiChatClient.reset();\n mailAiProperties.setEnabled(true);\n mailAiProperties.setApiKey(\"test-key\");\n }\n\n @Test\n void rejectAiAnalysisWithoutToken() throws Exception {\n mockMvc.perform(post(\"/api/mail-ai/accounts/1/messages/1/analysis\"))\n .andExpect(status().isUnauthorized());\n }\n\n @Test\n void analyzeMessageForOwner() throws Exception {\n String token = registerAndToken(\"ai-owner@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-owner@163.com\", \"AI Owner\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"项目方案确认\", \"请在周五前补充预算测算并同步修改版。授权码不应出现。\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk())\n .andExpect(jsonPath(\"$.emailType\").value(\"WORK\"))\n .andExpect(jsonPath(\"$.brief\").value(\"这封邮件要求在周五前补充预算测算。\"))\n .andExpect(jsonPath(\"$.keyPoints[0]\").value(\"需要补充预算测算\"))\n .andExpect(jsonPath(\"$.senderIntent.type\").value(\"REQUEST_ACTION\"))\n .andExpect(jsonPath(\"$.tone.type\").value(\"URGENT\"))\n .andExpect(jsonPath(\"$.actionItems[0].priority\").value(\"HIGH\"))\n .andExpect(jsonPath(\"$.suggestedResponse.draft\").value(\"收到,我会在周五前补充预算测算并同步修改版。\"))\n .andExpect(jsonPath(\"$.model\").value(\"test-model\"))\n .andExpect(jsonPath(\"$.analyzedAt\").exists());\n\n assertThat(aiChatClient.lastUserPrompt()).contains(\"项目方案确认\");\n assertThat(aiChatClient.lastUserPrompt()).contains(\"请在周五前补充预算测算\");\n assertThat(aiChatClient.lastUserPrompt()).doesNotContain(\"message-auth-code\");\n }\n\n @Test\n void usersCannotAnalyzeEachOthersMessages() throws Exception {\n String ownerToken = registerAndToken(\"ai-private@mailnest.local\");\n String otherToken = registerAndToken(\"ai-other@mailnest.local\");\n String accountId = createMailAccount(ownerToken, \"ai-private@163.com\", \"AI Private\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"Private\", \"Private body\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + otherToken))\n .andExpect(status().isNotFound());\n }\n\n @Test\n void rejectMissingMessage() throws Exception {\n String token = registerAndToken(\"ai-missing@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-missing@163.com\", \"AI Missing\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/999999/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isNotFound())\n .andExpect(jsonPath(\"$.detail\").value(\"邮件不存在\"));\n }\n\n @Test\n void rejectInvalidAiJson() throws Exception {\n String token = registerAndToken(\"ai-invalid@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-invalid@163.com\", \"AI Invalid\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"Invalid\", \"Invalid body\");\n aiChatClient.setNextResponse(\"not json\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isBadGateway())\n .andExpect(jsonPath(\"$.detail\").value(\"AI 返回格式异常\"));\n }\n\n @Test\n void rejectDisabledAiService() throws Exception {\n String token = registerAndToken(\"ai-disabled@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-disabled@163.com\", \"AI Disabled\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"Disabled\", \"Disabled body\");\n mailAiProperties.setEnabled(false);\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isServiceUnavailable())\n .andExpect(jsonPath(\"$.detail\").value(\"AI 服务未启用\"));\n }\n\n @Test\n void rejectMissingApiKey() throws Exception {\n String token = registerAndToken(\"ai-no-key@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-no-key@163.com\", \"AI No Key\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"No Key\", \"No key body\");\n mailAiProperties.setApiKey(\"\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isServiceUnavailable())\n .andExpect(jsonPath(\"$.detail\").value(\"AI 服务未配置,请先设置 DEEPSEEK_API_KEY\"));\n }\n\n private String registerAndToken(String email) throws Exception {\n String response = mockMvc.perform(post(\"/api/auth/register\")\n .contentType(\"application/json\")\n .content(\"\"\"\n {\"email\":\"%s\",\"password\":\"password123\",\"displayName\":\"Test User\"}\n \"\"\".formatted(email)))\n .andExpect(status().isCreated())\n .andReturn()\n .getResponse()\n .getContentAsString();\n return response.replaceAll(\".*\\\\\\\"accessToken\\\\\\\":\\\\\\\"([^\\\\\\\"]+)\\\\\\\".*\", \"$1\");\n }\n\n private String createMailAccount(String token, String emailAddress, String displayName) throws Exception {\n String response = mockMvc.perform(post(\"/api/mail-accounts\")\n .header(\"Authorization\", \"Bearer \" + token)\n .contentType(\"application/json\")\n .content(\"\"\"\n {\n \"emailAddress\":\"%s\",\n \"displayName\":\"%s\",\n \"provider\":\"163\",\n \"authUsername\":\"%s\",\n \"authSecret\":\"message-auth-code\",\n \"enabled\":true\n }\n \"\"\".formatted(emailAddress, displayName, emailAddress)))\n .andExpect(status().isCreated())\n .andReturn()\n .getResponse()\n .getContentAsString();\n return extractJsonNumber(response, \"id\");\n }\n\n private MailMessage insertMessage(Long accountId, String subject, String body) {\n MailMessage mailMessage = new MailMessage();\n mailMessage.setAccountId(accountId);\n mailMessage.setFolderId(1L);\n mailMessage.setUid(System.nanoTime());\n mailMessage.setMessageId(\"\");\n mailMessage.setSubject(subject);\n mailMessage.setFromAddress(\"sender@example.com\");\n mailMessage.setToAddresses(\"owner@example.com\");\n mailMessage.setSentAt(LocalDateTime.of(2026, 5, 19, 9, 0));\n mailMessage.setReceivedAt(LocalDateTime.of(2026, 5, 19, 9, 1));\n mailMessage.setSnippet(body.length() > 50 ? body.substring(0, 50) : body);\n mailMessage.setTextBody(body);\n mailMessage.setHtmlBody(\"

\" + body + \"

\");\n mailMessage.setReadFlag(true);\n mailMessage.setHasAttachments(false);\n mailMessageMapper.insert(mailMessage);\n return mailMessage;\n }\n\n private String extractJsonNumber(String json, String fieldName) {\n return json.replaceAll(\".*\\\\\\\"\" + fieldName + \"\\\\\\\":([0-9]+).*\", \"$1\");\n }\n\n @TestConfiguration\n static class MailAiTestConfig {\n\n @Bean\n @Primary\n RecordingAiChatClient recordingAiChatClient() {\n return new RecordingAiChatClient();\n }\n }\n\n static class RecordingAiChatClient implements AiChatClient {\n\n private String nextResponse = validResponse();\n private String lastSystemPrompt;\n private String lastUserPrompt;\n\n @Override\n public String completeJson(String systemPrompt, String userPrompt) {\n this.lastSystemPrompt = systemPrompt;\n this.lastUserPrompt = userPrompt;\n return nextResponse;\n }\n\n void setNextResponse(String nextResponse) {\n this.nextResponse = nextResponse;\n }\n\n String lastUserPrompt() {\n return lastUserPrompt;\n }\n\n void reset() {\n this.nextResponse = validResponse();\n this.lastSystemPrompt = null;\n this.lastUserPrompt = null;\n }\n\n private static String validResponse() {\n return \"\"\"\n {\n \"emailType\": \"WORK\",\n \"brief\": \"这封邮件要求在周五前补充预算测算。\",\n \"keyPoints\": [\"需要补充预算测算\", \"需要同步修改版\"],\n \"senderIntent\": {\n \"type\": \"REQUEST_ACTION\",\n \"label\": \"要求行动\",\n \"explanation\": \"发件人希望收件人完成修改并反馈。\"\n },\n \"tone\": {\n \"type\": \"URGENT\",\n \"label\": \"紧急\",\n \"confidence\": \"MEDIUM\",\n \"signals\": [\"出现周五前的时间要求\"],\n \"caution\": \"该判断仅基于当前邮件文本。\"\n },\n \"actionItems\": [\n {\n \"task\": \"补充预算测算\",\n \"deadline\": \"周五前\",\n \"priority\": \"HIGH\",\n \"evidence\": \"请在周五前补充预算测算\"\n }\n ],\n \"risks\": [],\n \"nextStep\": \"先确认任务范围,再按时提交修改版。\",\n \"suggestedResponse\": {\n \"strategy\": \"简洁确认任务和时间点。\",\n \"draft\": \"收到,我会在周五前补充预算测算并同步修改版。\"\n }\n }\n \"\"\";\n }\n }\n}\n"}, "2915b55690605c337a0a3ba3f24aae9b20db18de": {"t": 1785751008.1701176, "n": 11758, "h": "1fe4f88f60f9c21d", "c": "package com.mailnest.mailai;\n\nimport com.mailnest.mailmessage.MailMessage;\nimport com.mailnest.mailmessage.MailMessageMapper;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.boot.test.context.TestConfiguration;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Primary;\nimport org.springframework.test.web.servlet.MockMvc;\n\nimport java.time.LocalDateTime;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\n@SpringBootTest(properties = {\n \"mailnest.ai.enabled=true\",\n \"mailnest.ai.api-key=test-key\",\n \"mailnest.ai.model=test-model\",\n \"mailnest.ai.max-input-chars=120\"\n})\n@org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc\nclass MailAiControllerTest {\n\n @Autowired\n private MockMvc mockMvc;\n\n @Autowired\n private MailMessageMapper mailMessageMapper;\n\n @Autowired\n private RecordingAiChatClient aiChatClient;\n\n @Autowired\n private MailAiProperties mailAiProperties;\n\n @AfterEach\n void resetAi() {\n aiChatClient.reset();\n mailAiProperties.setEnabled(true);\n mailAiProperties.setApiKey(\"test-key\");\n }\n\n @Test\n void rejectAiAnalysisWithoutToken() throws Exception {\n mockMvc.perform(post(\"/api/mail-ai/accounts/1/messages/1/analysis\"))\n .andExpect(status().isUnauthorized());\n }\n\n @Test\n void analyzeMessageForOwner() throws Exception {\n String token = registerAndToken(\"ai-owner@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-owner@163.com\", \"AI Owner\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"项目方案确认\", \"请在周五前补充预算测算并同步修改版。授权码不应出现。\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isOk())\n .andExpect(jsonPath(\"$.emailType\").value(\"WORK\"))\n .andExpect(jsonPath(\"$.brief\").value(\"这封邮件要求在周五前补充预算测算。\"))\n .andExpect(jsonPath(\"$.keyPoints[0]\").value(\"需要补充预算测算\"))\n .andExpect(jsonPath(\"$.senderIntent.type\").value(\"REQUEST_ACTION\"))\n .andExpect(jsonPath(\"$.tone.type\").value(\"URGENT\"))\n .andExpect(jsonPath(\"$.actionItems[0].priority\").value(\"HIGH\"))\n .andExpect(jsonPath(\"$.suggestedResponse.draft\").value(\"收到,我会在周五前补充预算测算并同步修改版。\"))\n .andExpect(jsonPath(\"$.model\").value(\"test-model\"))\n .andExpect(jsonPath(\"$.analyzedAt\").exists());\n\n assertThat(aiChatClient.lastUserPrompt()).contains(\"项目方案确认\");\n assertThat(aiChatClient.lastUserPrompt()).contains(\"请在周五前补充预算测算\");\n assertThat(aiChatClient.lastUserPrompt()).doesNotContain(\"message-auth-code\");\n }\n\n @Test\n void usersCannotAnalyzeEachOthersMessages() throws Exception {\n String ownerToken = registerAndToken(\"ai-private@mailnest.local\");\n String otherToken = registerAndToken(\"ai-other@mailnest.local\");\n String accountId = createMailAccount(ownerToken, \"ai-private@163.com\", \"AI Private\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"Private\", \"Private body\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + otherToken))\n .andExpect(status().isNotFound());\n }\n\n @Test\n void rejectMissingMessage() throws Exception {\n String token = registerAndToken(\"ai-missing@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-missing@163.com\", \"AI Missing\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/999999/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isNotFound())\n .andExpect(jsonPath(\"$.detail\").value(\"邮件不存在\"));\n }\n\n @Test\n void rejectInvalidAiJson() throws Exception {\n String token = registerAndToken(\"ai-invalid@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-invalid@163.com\", \"AI Invalid\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"Invalid\", \"Invalid body\");\n aiChatClient.setNextResponse(\"not json\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isBadGateway())\n .andExpect(jsonPath(\"$.detail\").value(\"AI 返回格式异常\"));\n }\n\n @Test\n void rejectDisabledAiService() throws Exception {\n String token = registerAndToken(\"ai-disabled@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-disabled@163.com\", \"AI Disabled\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"Disabled\", \"Disabled body\");\n mailAiProperties.setEnabled(false);\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isServiceUnavailable())\n .andExpect(jsonPath(\"$.detail\").value(\"AI 服务未启用\"));\n }\n\n @Test\n void rejectMissingApiKey() throws Exception {\n String token = registerAndToken(\"ai-no-key@mailnest.local\");\n String accountId = createMailAccount(token, \"ai-no-key@163.com\", \"AI No Key\");\n MailMessage mailMessage = insertMessage(Long.valueOf(accountId), \"No Key\", \"No key body\");\n mailAiProperties.setApiKey(\"\");\n\n mockMvc.perform(post(\"/api/mail-ai/accounts/\" + accountId + \"/messages/\" + mailMessage.getId() + \"/analysis\")\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isServiceUnavailable())\n .andExpect(jsonPath(\"$.detail\").value(\"AI 服务未配置,请先设置 SILICONFLOW_API_KEY\"));\n }\n\n private String registerAndToken(String email) throws Exception {\n String response = mockMvc.perform(post(\"/api/auth/register\")\n .contentType(\"application/json\")\n .content(\"\"\"\n {\"email\":\"%s\",\"password\":\"password123\",\"displayName\":\"Test User\"}\n \"\"\".formatted(email)))\n .andExpect(status().isCreated())\n .andReturn()\n .getResponse()\n .getContentAsString();\n return response.replaceAll(\".*\\\\\\\"accessToken\\\\\\\":\\\\\\\"([^\\\\\\\"]+)\\\\\\\".*\", \"$1\");\n }\n\n private String createMailAccount(String token, String emailAddress, String displayName) throws Exception {\n String response = mockMvc.perform(post(\"/api/mail-accounts\")\n .header(\"Authorization\", \"Bearer \" + token)\n .contentType(\"application/json\")\n .content(\"\"\"\n {\n \"emailAddress\":\"%s\",\n \"displayName\":\"%s\",\n \"provider\":\"163\",\n \"authUsername\":\"%s\",\n \"authSecret\":\"message-auth-code\",\n \"enabled\":true\n }\n \"\"\".formatted(emailAddress, displayName, emailAddress)))\n .andExpect(status().isCreated())\n .andReturn()\n .getResponse()\n .getContentAsString();\n return extractJsonNumber(response, \"id\");\n }\n\n private MailMessage insertMessage(Long accountId, String subject, String body) {\n MailMessage mailMessage = new MailMessage();\n mailMessage.setAccountId(accountId);\n mailMessage.setFolderId(1L);\n mailMessage.setUid(System.nanoTime());\n mailMessage.setMessageId(\"\");\n mailMessage.setSubject(subject);\n mailMessage.setFromAddress(\"sender@example.com\");\n mailMessage.setToAddresses(\"owner@example.com\");\n mailMessage.setSentAt(LocalDateTime.of(2026, 5, 19, 9, 0));\n mailMessage.setReceivedAt(LocalDateTime.of(2026, 5, 19, 9, 1));\n mailMessage.setSnippet(body.length() > 50 ? body.substring(0, 50) : body);\n mailMessage.setTextBody(body);\n mailMessage.setHtmlBody(\"

\" + body + \"

\");\n mailMessage.setReadFlag(true);\n mailMessage.setHasAttachments(false);\n mailMessageMapper.insert(mailMessage);\n return mailMessage;\n }\n\n private String extractJsonNumber(String json, String fieldName) {\n return json.replaceAll(\".*\\\\\\\"\" + fieldName + \"\\\\\\\":([0-9]+).*\", \"$1\");\n }\n\n @TestConfiguration\n static class MailAiTestConfig {\n\n @Bean\n @Primary\n RecordingAiChatClient recordingAiChatClient() {\n return new RecordingAiChatClient();\n }\n }\n\n static class RecordingAiChatClient implements AiChatClient {\n\n private String nextResponse = validResponse();\n private String lastSystemPrompt;\n private String lastUserPrompt;\n\n @Override\n public String completeJson(String systemPrompt, String userPrompt) {\n this.lastSystemPrompt = systemPrompt;\n this.lastUserPrompt = userPrompt;\n return nextResponse;\n }\n\n void setNextResponse(String nextResponse) {\n this.nextResponse = nextResponse;\n }\n\n String lastUserPrompt() {\n return lastUserPrompt;\n }\n\n void reset() {\n this.nextResponse = validResponse();\n this.lastSystemPrompt = null;\n this.lastUserPrompt = null;\n }\n\n private static String validResponse() {\n return \"\"\"\n {\n \"emailType\": \"WORK\",\n \"brief\": \"这封邮件要求在周五前补充预算测算。\",\n \"keyPoints\": [\"需要补充预算测算\", \"需要同步修改版\"],\n \"senderIntent\": {\n \"type\": \"REQUEST_ACTION\",\n \"label\": \"要求行动\",\n \"explanation\": \"发件人希望收件人完成修改并反馈。\"\n },\n \"tone\": {\n \"type\": \"URGENT\",\n \"label\": \"紧急\",\n \"confidence\": \"MEDIUM\",\n \"signals\": [\"出现周五前的时间要求\"],\n \"caution\": \"该判断仅基于当前邮件文本。\"\n },\n \"actionItems\": [\n {\n \"task\": \"补充预算测算\",\n \"deadline\": \"周五前\",\n \"priority\": \"HIGH\",\n \"evidence\": \"请在周五前补充预算测算\"\n }\n ],\n \"risks\": [],\n \"nextStep\": \"先确认任务范围,再按时提交修改版。\",\n \"suggestedResponse\": {\n \"strategy\": \"简洁确认任务和时间点。\",\n \"draft\": \"收到,我会在周五前补充预算测算并同步修改版。\"\n }\n }\n \"\"\";\n }\n }\n}\n"}}, "backend/src/main/java/com/mailnest/mailai/MailAiService.java": {"da822d85dd1cf24cf2b9238faaf3389aa93275d6": {"t": 1785745699.6263142, "n": 17100, "h": "bc120aa30251beff", "c": "package com.mailnest.mailai;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.mailnest.common.ResourceNotFoundException;\nimport com.mailnest.mailaccount.MailAccountMapper;\nimport com.mailnest.mailai.dto.MailAiAnalysisResponse;\nimport com.mailnest.mailai.dto.SuggestedReplyRequest;\nimport com.mailnest.mailai.dto.SuggestedResponse;\nimport com.mailnest.mailmessage.MailMessage;\nimport com.mailnest.mailmessage.MailMessageMapper;\nimport com.mailnest.security.CurrentUser;\nimport com.mailnest.settings.UserSettingsService;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.stereotype.Service;\n\nimport java.time.LocalDateTime;\n\n@Service\npublic class MailAiService {\n\n private static final Logger log = LoggerFactory.getLogger(MailAiService.class);\n\n private static final String SYSTEM_PROMPT = \"\"\"\n 你是 MailNest 的邮件分析助手,服务对象是需要快速理解和处理邮件的普通用户。\n\n 你的任务不是泛泛总结邮件,而是帮助用户判断:\n - 这封邮件属于什么类型;\n - 发件人的主要意图是什么;\n - 邮件有哪些关键重点;\n - 是否有明确待处理事项、截止时间或优先级;\n - 语气是否中性、友好、催促、紧急、可疑或存在情绪压力;\n - 用户下一步应该如何处理。\n\n 你必须遵守:\n 1. 只基于用户提供的邮件内容分析,不要编造背景、关系或事实。\n 2. 如果邮件内容不足以判断,请明确说明“不确定”。\n 3. 对“情绪操控、施压、可疑风险”等判断必须谨慎,只能作为可能信号,不能下绝对结论。\n 4. 如果指出语气或风险信号,必须给出邮件文本中的依据。\n 5. 输出应帮助用户冷静、专业、高效地处理邮件,不要激化冲突。\n 6. suggestedResponse.draft 必须是一段可以直接放入发信正文的纯文本回复,不要包含 Markdown、JSON、标题或字段名。\n 7. 如果判断需要回复,suggestedResponse.required 必须为 true,并尽量给出简短、稳妥、可直接编辑的 suggestedResponse.draft。\n 8. 使用简体中文。\n 9. 只输出合法 JSON,不要输出 Markdown,不要添加额外解释。\n \"\"\";\n\n private static final String REPLY_SYSTEM_PROMPT = \"\"\"\n 你是 MailNest 的邮件回复助手。请基于邮件内容、已有分析和用户额外要求,生成一段适合直接复制修改的中文回复建议。\n\n 你必须遵守:\n 1. 只基于邮件和用户要求生成回复,不要编造事实、承诺、时间或关系。\n 2. 如果邮件是广告、验证码、系统通知、账单提醒等通常无需回复的类型,应明确 required=false,并说明原因。\n 3. 如果用户仍然要求回复,可以生成礼貌、克制、低风险的回复草稿。\n 4. 如果用户要求与邮件事实冲突,请用稳妥表达,不要替用户作出无法确认的承诺。\n 5. draft 必须是一段可以直接放入发信正文的纯文本回复,不要包含 Markdown、JSON、标题或字段名。\n 6. 只输出合法 JSON,不要输出 Markdown,不要添加额外解释。\n \"\"\";\n\n private final MailAccountMapper mailAccountMapper;\n private final MailMessageMapper mailMessageMapper;\n private final MailAiAnalysisMapper mailAiAnalysisMapper;\n private final AiChatClient aiChatClient;\n private final MailAiProperties properties;\n private final UserSettingsService userSettingsService;\n private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();\n\n public MailAiService(MailAccountMapper mailAccountMapper,\n MailMessageMapper mailMessageMapper,\n MailAiAnalysisMapper mailAiAnalysisMapper,\n AiChatClient aiChatClient,\n MailAiProperties properties,\n UserSettingsService userSettingsService) {\n this.mailAccountMapper = mailAccountMapper;\n this.mailMessageMapper = mailMessageMapper;\n this.mailAiAnalysisMapper = mailAiAnalysisMapper;\n this.aiChatClient = aiChatClient;\n this.properties = properties;\n this.userSettingsService = userSettingsService;\n }\n\n public MailAiAnalysisResponse getAnalysis(CurrentUser currentUser, Long accountId, Long messageId) {\n validateMessageAccess(currentUser, accountId, messageId);\n return mailAiAnalysisMapper.findByAccountIdAndMessageId(accountId, messageId)\n .map(this::toResponse)\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 分析结果不存在\"));\n }\n\n public MailAiAnalysisResponse analyze(CurrentUser currentUser, Long accountId, Long messageId) {\n String apiKeyOverride = resolvePersonalAiApiKey(currentUser);\n ensureAiConfigured(apiKeyOverride);\n MailMessage mailMessage = validateMessageAccess(currentUser, accountId, messageId);\n MailAiAnalysis cached = mailAiAnalysisMapper.findByAccountIdAndMessageId(accountId, messageId).orElse(null);\n if (cached != null) {\n return toResponse(cached);\n }\n long startedAt = System.nanoTime();\n log.info(\"Mail AI analysis started accountId={}, messageId={}, model={}\", accountId, messageId, properties.getModel());\n try {\n String modelJson = aiChatClient.completeJson(SYSTEM_PROMPT, buildUserPrompt(mailMessage), apiKeyOverride);\n MailAiAnalysisResponse parsed = parseAnalysis(modelJson);\n LocalDateTime analyzedAt = LocalDateTime.now();\n MailAiAnalysisResponse response = withMetadata(parsed, analyzedAt, null, null);\n MailAiAnalysis analysis = new MailAiAnalysis();\n analysis.setAccountId(accountId);\n analysis.setMessageId(messageId);\n analysis.setModel(properties.getModel());\n analysis.setAnalysisJson(objectMapper.writeValueAsString(response));\n analysis.setAnalyzedAt(analyzedAt);\n mailAiAnalysisMapper.insert(analysis);\n log.info(\"Mail AI analysis completed accountId={}, messageId={}, model={}, cached=false, time={}s\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt));\n return response;\n } catch (JsonProcessingException ex) {\n log.warn(\"Mail AI analysis invalid JSON accountId={}, messageId={}, model={}, time={}s\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt));\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n } catch (RuntimeException ex) {\n log.warn(\"Mail AI analysis failed accountId={}, messageId={}, model={}, time={}s, reason={}\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt), ex.getMessage());\n throw ex;\n }\n }\n\n public SuggestedResponse generateSuggestedReply(CurrentUser currentUser, Long accountId, Long messageId, SuggestedReplyRequest request) {\n String apiKeyOverride = resolvePersonalAiApiKey(currentUser);\n ensureAiConfigured(apiKeyOverride);\n MailMessage mailMessage = validateMessageAccess(currentUser, accountId, messageId);\n MailAiAnalysis analysis = mailAiAnalysisMapper.findByAccountIdAndMessageId(accountId, messageId)\n .orElseGet(() -> createAnalysisSnapshot(accountId, messageId, analyze(currentUser, accountId, messageId)));\n String instruction = request == null ? \"\" : safe(request.userInstruction()).trim();\n long startedAt = System.nanoTime();\n log.info(\"Mail AI suggested reply started accountId={}, messageId={}, model={}\", accountId, messageId, properties.getModel());\n try {\n SuggestedResponse suggestedResponse = objectMapper.readValue(\n aiChatClient.completeJson(REPLY_SYSTEM_PROMPT, buildReplyPrompt(mailMessage, toResponse(analysis), instruction), apiKeyOverride),\n SuggestedResponse.class\n );\n analysis.setReplyInstruction(instruction);\n analysis.setCustomSuggestedResponseJson(objectMapper.writeValueAsString(suggestedResponse));\n mailAiAnalysisMapper.updateSuggestedResponse(analysis);\n log.info(\"Mail AI suggested reply completed accountId={}, messageId={}, model={}, time={}s\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt));\n return suggestedResponse;\n } catch (JsonProcessingException ex) {\n log.warn(\"Mail AI suggested reply invalid JSON accountId={}, messageId={}, model={}, time={}s\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt));\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n } catch (RuntimeException ex) {\n log.warn(\"Mail AI suggested reply failed accountId={}, messageId={}, model={}, time={}s, reason={}\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt), ex.getMessage());\n throw ex;\n }\n }\n\n private String buildUserPrompt(MailMessage mailMessage) {\n BodyInput bodyInput = buildBodyInput(mailMessage);\n return \"\"\"\n 请分析下面这封邮件,并输出结构化 JSON。\n\n 输出格式:\n {\n \"emailType\": \"WORK | PERSONAL | NOTIFICATION | BILL | VERIFICATION | MARKETING | SYSTEM | RISK | OTHER | UNKNOWN\",\n \"brief\": \"一句话概括邮件核心内容,最多 100 个中文字符\",\n \"keyPoints\": [\"关键点 1\", \"关键点 2\"],\n \"senderIntent\": {\n \"type\": \"INFORM_ONLY | REQUEST_ACTION | REQUEST_REPLY | DELEGATE_TASK | FOLLOW_UP | FEEDBACK | ENCOURAGEMENT | WARNING | PRESSURE | NEGOTIATION | PROMOTION | VERIFY_IDENTITY | PAYMENT_RELATED | UNKNOWN\",\n \"label\": \"中文标签\",\n \"explanation\": \"简要解释发件人意图\"\n },\n \"tone\": {\n \"type\": \"NEUTRAL | FRIENDLY | ENCOURAGING | FORMAL | URGENT | PRESSURING | CRITICAL | PASSIVE_AGGRESSIVE | MANIPULATIVE | SUSPICIOUS | UNCLEAR\",\n \"label\": \"中文标签\",\n \"confidence\": \"LOW | MEDIUM | HIGH\",\n \"signals\": [\"判断依据 1\", \"判断依据 2\"],\n \"caution\": \"谨慎提示,如果没有特殊风险则说明仅供参考\"\n },\n \"actionItems\": [\n {\n \"task\": \"需要处理的事项\",\n \"deadline\": \"截止时间,没有则写“不明确”\",\n \"priority\": \"LOW | MEDIUM | HIGH | URGENT | UNKNOWN\",\n \"evidence\": \"邮件中的文本依据\"\n }\n ],\n \"risks\": [\"风险或注意点,没有则返回空数组\"],\n \"nextStep\": \"建议下一步操作,最多 120 个中文字符\",\n \"suggestedResponse\": {\n \"required\": true,\n \"reason\": \"是否需要回复以及原因。广告、验证码、系统通知通常不需要回复\",\n \"strategy\": \"建议回复策略,没有必要回复则说明无需回复\",\n \"draft\": \"一段可直接放入发信正文的纯文本回复草稿,如果无需回复则为空字符串\"\n }\n }\n\n 注意:\n - 不要编造邮件中没有的截止时间、任务或关系。\n - 如果没有待处理事项,actionItems 返回空数组。\n - 如果没有明显风险,risks 返回空数组。\n - 如果语气正常,不要强行指出负面含义。\n - 如果可能存在情绪压力或可疑风险,请给出具体文本依据,并保持谨慎表述。\n %s\n\n 邮件信息:\n 主题:%s\n 发件人:%s\n 收件人:%s\n 发送时间:%s\n 接收时间:%s\n 摘要:%s\n 正文:\n %s\n \"\"\".formatted(\n bodyInput.truncated() ? \"- 正文因长度限制已截断,请只基于可见内容判断。\" : \"\",\n safe(mailMessage.getSubject()),\n safe(mailMessage.getFromAddress()),\n safe(mailMessage.getToAddresses()),\n safe(mailMessage.getSentAt()),\n safe(mailMessage.getReceivedAt()),\n safe(mailMessage.getSnippet()),\n bodyInput.body()\n );\n }\n\n private BodyInput buildBodyInput(MailMessage mailMessage) {\n String body = mailMessage.getTextBody();\n if (body == null || body.isBlank()) {\n body = stripHtml(mailMessage.getHtmlBody());\n }\n body = safe(body);\n int maxChars = Math.max(1000, properties.getMaxInputChars());\n if (body.length() <= maxChars) {\n return new BodyInput(body, false);\n }\n return new BodyInput(body.substring(0, maxChars), true);\n }\n\n private String stripHtml(String html) {\n if (html == null || html.isBlank()) {\n return \"\";\n }\n return html.replaceAll(\"<[^>]*>\", \" \").replaceAll(\"\\\\s+\", \" \").trim();\n }\n\n private MailMessage validateMessageAccess(CurrentUser currentUser, Long accountId, Long messageId) {\n mailAccountMapper.findByIdAndUserId(accountId, currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"邮箱账号不存在\"));\n return mailMessageMapper.findByIdAndAccountId(messageId, accountId)\n .orElseThrow(() -> new ResourceNotFoundException(\"邮件不存在\"));\n }\n\n private void ensureAiConfigured(String apiKeyOverride) {\n if (!properties.isEnabled()) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未启用\");\n }\n if ((apiKeyOverride == null || apiKeyOverride.isBlank()) && (properties.getApiKey() == null || properties.getApiKey().isBlank())) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未配置,请先设置 DEEPSEEK_API_KEY 或个人 AI API Key\");\n }\n }\n\n private String resolvePersonalAiApiKey(CurrentUser currentUser) {\n return userSettingsService.resolveAiApiKey(currentUser.id());\n }\n\n private MailAiAnalysisResponse parseAnalysis(String modelJson) throws JsonProcessingException {\n return objectMapper.readValue(modelJson, MailAiAnalysisResponse.class);\n }\n\n private MailAiAnalysisResponse withMetadata(MailAiAnalysisResponse parsed,\n LocalDateTime analyzedAt,\n String replyInstruction,\n SuggestedResponse customSuggestedResponse) {\n return new MailAiAnalysisResponse(\n parsed.emailType(),\n parsed.brief(),\n parsed.keyPoints(),\n parsed.senderIntent(),\n parsed.tone(),\n parsed.actionItems(),\n parsed.risks(),\n parsed.nextStep(),\n parsed.suggestedResponse(),\n properties.getModel(),\n analyzedAt,\n replyInstruction,\n customSuggestedResponse\n );\n }\n\n private MailAiAnalysisResponse toResponse(MailAiAnalysis analysis) {\n try {\n MailAiAnalysisResponse parsed = objectMapper.readValue(analysis.getAnalysisJson(), MailAiAnalysisResponse.class);\n SuggestedResponse customSuggestedResponse = analysis.getCustomSuggestedResponseJson() == null || analysis.getCustomSuggestedResponseJson().isBlank()\n ? null\n : objectMapper.readValue(analysis.getCustomSuggestedResponseJson(), SuggestedResponse.class);\n return new MailAiAnalysisResponse(\n parsed.emailType(),\n parsed.brief(),\n parsed.keyPoints(),\n parsed.senderIntent(),\n parsed.tone(),\n parsed.actionItems(),\n parsed.risks(),\n parsed.nextStep(),\n parsed.suggestedResponse(),\n analysis.getModel(),\n analysis.getAnalyzedAt(),\n analysis.getReplyInstruction(),\n customSuggestedResponse\n );\n } catch (JsonProcessingException ex) {\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 分析缓存格式异常\");\n }\n }\n\n private MailAiAnalysis createAnalysisSnapshot(Long accountId, Long messageId, MailAiAnalysisResponse response) {\n MailAiAnalysis analysis = new MailAiAnalysis();\n analysis.setAccountId(accountId);\n analysis.setMessageId(messageId);\n analysis.setModel(response.model());\n try {\n analysis.setAnalysisJson(objectMapper.writeValueAsString(response));\n } catch (JsonProcessingException ex) {\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 分析缓存格式异常\");\n }\n analysis.setAnalyzedAt(response.analyzedAt());\n return analysis;\n }\n\n private String buildReplyPrompt(MailMessage mailMessage, MailAiAnalysisResponse analysis, String instruction) throws JsonProcessingException {\n BodyInput bodyInput = buildBodyInput(mailMessage);\n return \"\"\"\n 请为下面这封邮件生成建议回复 JSON。\n\n 输出格式:\n {\n \"required\": true,\n \"reason\": \"是否需要回复以及原因\",\n \"strategy\": \"回复策略\",\n \"draft\": \"可直接放入发信正文的纯文本回复草稿,如果无需回复则为空字符串\"\n }\n\n 用户额外要求:%s\n\n 已有分析:\n %s\n\n 邮件信息:\n 主题:%s\n 发件人:%s\n 收件人:%s\n 正文:\n %s\n \"\"\".formatted(\n instruction.isBlank() ? \"无\" : instruction,\n objectMapper.writeValueAsString(analysis),\n safe(mailMessage.getSubject()),\n safe(mailMessage.getFromAddress()),\n safe(mailMessage.getToAddresses()),\n bodyInput.body()\n );\n }\n\n private String elapsedSeconds(long startedAt) {\n return String.format(\"%.2f\", (System.nanoTime() - startedAt) / 1_000_000_000.0);\n }\n\n private String safe(Object value) {\n return value == null ? \"\" : value.toString();\n }\n\n private record BodyInput(String body, boolean truncated) {\n }\n}\n"}, "af68111df9753ff0b155376b7aad7c42a9de6246": {"t": 1785751005.7568724, "n": 17100, "h": "bc120aa30251beff", "c": "package com.mailnest.mailai;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.mailnest.common.ResourceNotFoundException;\nimport com.mailnest.mailaccount.MailAccountMapper;\nimport com.mailnest.mailai.dto.MailAiAnalysisResponse;\nimport com.mailnest.mailai.dto.SuggestedReplyRequest;\nimport com.mailnest.mailai.dto.SuggestedResponse;\nimport com.mailnest.mailmessage.MailMessage;\nimport com.mailnest.mailmessage.MailMessageMapper;\nimport com.mailnest.security.CurrentUser;\nimport com.mailnest.settings.UserSettingsService;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.stereotype.Service;\n\nimport java.time.LocalDateTime;\n\n@Service\npublic class MailAiService {\n\n private static final Logger log = LoggerFactory.getLogger(MailAiService.class);\n\n private static final String SYSTEM_PROMPT = \"\"\"\n 你是 MailNest 的邮件分析助手,服务对象是需要快速理解和处理邮件的普通用户。\n\n 你的任务不是泛泛总结邮件,而是帮助用户判断:\n - 这封邮件属于什么类型;\n - 发件人的主要意图是什么;\n - 邮件有哪些关键重点;\n - 是否有明确待处理事项、截止时间或优先级;\n - 语气是否中性、友好、催促、紧急、可疑或存在情绪压力;\n - 用户下一步应该如何处理。\n\n 你必须遵守:\n 1. 只基于用户提供的邮件内容分析,不要编造背景、关系或事实。\n 2. 如果邮件内容不足以判断,请明确说明“不确定”。\n 3. 对“情绪操控、施压、可疑风险”等判断必须谨慎,只能作为可能信号,不能下绝对结论。\n 4. 如果指出语气或风险信号,必须给出邮件文本中的依据。\n 5. 输出应帮助用户冷静、专业、高效地处理邮件,不要激化冲突。\n 6. suggestedResponse.draft 必须是一段可以直接放入发信正文的纯文本回复,不要包含 Markdown、JSON、标题或字段名。\n 7. 如果判断需要回复,suggestedResponse.required 必须为 true,并尽量给出简短、稳妥、可直接编辑的 suggestedResponse.draft。\n 8. 使用简体中文。\n 9. 只输出合法 JSON,不要输出 Markdown,不要添加额外解释。\n \"\"\";\n\n private static final String REPLY_SYSTEM_PROMPT = \"\"\"\n 你是 MailNest 的邮件回复助手。请基于邮件内容、已有分析和用户额外要求,生成一段适合直接复制修改的中文回复建议。\n\n 你必须遵守:\n 1. 只基于邮件和用户要求生成回复,不要编造事实、承诺、时间或关系。\n 2. 如果邮件是广告、验证码、系统通知、账单提醒等通常无需回复的类型,应明确 required=false,并说明原因。\n 3. 如果用户仍然要求回复,可以生成礼貌、克制、低风险的回复草稿。\n 4. 如果用户要求与邮件事实冲突,请用稳妥表达,不要替用户作出无法确认的承诺。\n 5. draft 必须是一段可以直接放入发信正文的纯文本回复,不要包含 Markdown、JSON、标题或字段名。\n 6. 只输出合法 JSON,不要输出 Markdown,不要添加额外解释。\n \"\"\";\n\n private final MailAccountMapper mailAccountMapper;\n private final MailMessageMapper mailMessageMapper;\n private final MailAiAnalysisMapper mailAiAnalysisMapper;\n private final AiChatClient aiChatClient;\n private final MailAiProperties properties;\n private final UserSettingsService userSettingsService;\n private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();\n\n public MailAiService(MailAccountMapper mailAccountMapper,\n MailMessageMapper mailMessageMapper,\n MailAiAnalysisMapper mailAiAnalysisMapper,\n AiChatClient aiChatClient,\n MailAiProperties properties,\n UserSettingsService userSettingsService) {\n this.mailAccountMapper = mailAccountMapper;\n this.mailMessageMapper = mailMessageMapper;\n this.mailAiAnalysisMapper = mailAiAnalysisMapper;\n this.aiChatClient = aiChatClient;\n this.properties = properties;\n this.userSettingsService = userSettingsService;\n }\n\n public MailAiAnalysisResponse getAnalysis(CurrentUser currentUser, Long accountId, Long messageId) {\n validateMessageAccess(currentUser, accountId, messageId);\n return mailAiAnalysisMapper.findByAccountIdAndMessageId(accountId, messageId)\n .map(this::toResponse)\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 分析结果不存在\"));\n }\n\n public MailAiAnalysisResponse analyze(CurrentUser currentUser, Long accountId, Long messageId) {\n String apiKeyOverride = resolvePersonalAiApiKey(currentUser);\n ensureAiConfigured(apiKeyOverride);\n MailMessage mailMessage = validateMessageAccess(currentUser, accountId, messageId);\n MailAiAnalysis cached = mailAiAnalysisMapper.findByAccountIdAndMessageId(accountId, messageId).orElse(null);\n if (cached != null) {\n return toResponse(cached);\n }\n long startedAt = System.nanoTime();\n log.info(\"Mail AI analysis started accountId={}, messageId={}, model={}\", accountId, messageId, properties.getModel());\n try {\n String modelJson = aiChatClient.completeJson(SYSTEM_PROMPT, buildUserPrompt(mailMessage), apiKeyOverride);\n MailAiAnalysisResponse parsed = parseAnalysis(modelJson);\n LocalDateTime analyzedAt = LocalDateTime.now();\n MailAiAnalysisResponse response = withMetadata(parsed, analyzedAt, null, null);\n MailAiAnalysis analysis = new MailAiAnalysis();\n analysis.setAccountId(accountId);\n analysis.setMessageId(messageId);\n analysis.setModel(properties.getModel());\n analysis.setAnalysisJson(objectMapper.writeValueAsString(response));\n analysis.setAnalyzedAt(analyzedAt);\n mailAiAnalysisMapper.insert(analysis);\n log.info(\"Mail AI analysis completed accountId={}, messageId={}, model={}, cached=false, time={}s\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt));\n return response;\n } catch (JsonProcessingException ex) {\n log.warn(\"Mail AI analysis invalid JSON accountId={}, messageId={}, model={}, time={}s\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt));\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n } catch (RuntimeException ex) {\n log.warn(\"Mail AI analysis failed accountId={}, messageId={}, model={}, time={}s, reason={}\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt), ex.getMessage());\n throw ex;\n }\n }\n\n public SuggestedResponse generateSuggestedReply(CurrentUser currentUser, Long accountId, Long messageId, SuggestedReplyRequest request) {\n String apiKeyOverride = resolvePersonalAiApiKey(currentUser);\n ensureAiConfigured(apiKeyOverride);\n MailMessage mailMessage = validateMessageAccess(currentUser, accountId, messageId);\n MailAiAnalysis analysis = mailAiAnalysisMapper.findByAccountIdAndMessageId(accountId, messageId)\n .orElseGet(() -> createAnalysisSnapshot(accountId, messageId, analyze(currentUser, accountId, messageId)));\n String instruction = request == null ? \"\" : safe(request.userInstruction()).trim();\n long startedAt = System.nanoTime();\n log.info(\"Mail AI suggested reply started accountId={}, messageId={}, model={}\", accountId, messageId, properties.getModel());\n try {\n SuggestedResponse suggestedResponse = objectMapper.readValue(\n aiChatClient.completeJson(REPLY_SYSTEM_PROMPT, buildReplyPrompt(mailMessage, toResponse(analysis), instruction), apiKeyOverride),\n SuggestedResponse.class\n );\n analysis.setReplyInstruction(instruction);\n analysis.setCustomSuggestedResponseJson(objectMapper.writeValueAsString(suggestedResponse));\n mailAiAnalysisMapper.updateSuggestedResponse(analysis);\n log.info(\"Mail AI suggested reply completed accountId={}, messageId={}, model={}, time={}s\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt));\n return suggestedResponse;\n } catch (JsonProcessingException ex) {\n log.warn(\"Mail AI suggested reply invalid JSON accountId={}, messageId={}, model={}, time={}s\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt));\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n } catch (RuntimeException ex) {\n log.warn(\"Mail AI suggested reply failed accountId={}, messageId={}, model={}, time={}s, reason={}\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt), ex.getMessage());\n throw ex;\n }\n }\n\n private String buildUserPrompt(MailMessage mailMessage) {\n BodyInput bodyInput = buildBodyInput(mailMessage);\n return \"\"\"\n 请分析下面这封邮件,并输出结构化 JSON。\n\n 输出格式:\n {\n \"emailType\": \"WORK | PERSONAL | NOTIFICATION | BILL | VERIFICATION | MARKETING | SYSTEM | RISK | OTHER | UNKNOWN\",\n \"brief\": \"一句话概括邮件核心内容,最多 100 个中文字符\",\n \"keyPoints\": [\"关键点 1\", \"关键点 2\"],\n \"senderIntent\": {\n \"type\": \"INFORM_ONLY | REQUEST_ACTION | REQUEST_REPLY | DELEGATE_TASK | FOLLOW_UP | FEEDBACK | ENCOURAGEMENT | WARNING | PRESSURE | NEGOTIATION | PROMOTION | VERIFY_IDENTITY | PAYMENT_RELATED | UNKNOWN\",\n \"label\": \"中文标签\",\n \"explanation\": \"简要解释发件人意图\"\n },\n \"tone\": {\n \"type\": \"NEUTRAL | FRIENDLY | ENCOURAGING | FORMAL | URGENT | PRESSURING | CRITICAL | PASSIVE_AGGRESSIVE | MANIPULATIVE | SUSPICIOUS | UNCLEAR\",\n \"label\": \"中文标签\",\n \"confidence\": \"LOW | MEDIUM | HIGH\",\n \"signals\": [\"判断依据 1\", \"判断依据 2\"],\n \"caution\": \"谨慎提示,如果没有特殊风险则说明仅供参考\"\n },\n \"actionItems\": [\n {\n \"task\": \"需要处理的事项\",\n \"deadline\": \"截止时间,没有则写“不明确”\",\n \"priority\": \"LOW | MEDIUM | HIGH | URGENT | UNKNOWN\",\n \"evidence\": \"邮件中的文本依据\"\n }\n ],\n \"risks\": [\"风险或注意点,没有则返回空数组\"],\n \"nextStep\": \"建议下一步操作,最多 120 个中文字符\",\n \"suggestedResponse\": {\n \"required\": true,\n \"reason\": \"是否需要回复以及原因。广告、验证码、系统通知通常不需要回复\",\n \"strategy\": \"建议回复策略,没有必要回复则说明无需回复\",\n \"draft\": \"一段可直接放入发信正文的纯文本回复草稿,如果无需回复则为空字符串\"\n }\n }\n\n 注意:\n - 不要编造邮件中没有的截止时间、任务或关系。\n - 如果没有待处理事项,actionItems 返回空数组。\n - 如果没有明显风险,risks 返回空数组。\n - 如果语气正常,不要强行指出负面含义。\n - 如果可能存在情绪压力或可疑风险,请给出具体文本依据,并保持谨慎表述。\n %s\n\n 邮件信息:\n 主题:%s\n 发件人:%s\n 收件人:%s\n 发送时间:%s\n 接收时间:%s\n 摘要:%s\n 正文:\n %s\n \"\"\".formatted(\n bodyInput.truncated() ? \"- 正文因长度限制已截断,请只基于可见内容判断。\" : \"\",\n safe(mailMessage.getSubject()),\n safe(mailMessage.getFromAddress()),\n safe(mailMessage.getToAddresses()),\n safe(mailMessage.getSentAt()),\n safe(mailMessage.getReceivedAt()),\n safe(mailMessage.getSnippet()),\n bodyInput.body()\n );\n }\n\n private BodyInput buildBodyInput(MailMessage mailMessage) {\n String body = mailMessage.getTextBody();\n if (body == null || body.isBlank()) {\n body = stripHtml(mailMessage.getHtmlBody());\n }\n body = safe(body);\n int maxChars = Math.max(1000, properties.getMaxInputChars());\n if (body.length() <= maxChars) {\n return new BodyInput(body, false);\n }\n return new BodyInput(body.substring(0, maxChars), true);\n }\n\n private String stripHtml(String html) {\n if (html == null || html.isBlank()) {\n return \"\";\n }\n return html.replaceAll(\"<[^>]*>\", \" \").replaceAll(\"\\\\s+\", \" \").trim();\n }\n\n private MailMessage validateMessageAccess(CurrentUser currentUser, Long accountId, Long messageId) {\n mailAccountMapper.findByIdAndUserId(accountId, currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"邮箱账号不存在\"));\n return mailMessageMapper.findByIdAndAccountId(messageId, accountId)\n .orElseThrow(() -> new ResourceNotFoundException(\"邮件不存在\"));\n }\n\n private void ensureAiConfigured(String apiKeyOverride) {\n if (!properties.isEnabled()) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未启用\");\n }\n if ((apiKeyOverride == null || apiKeyOverride.isBlank()) && (properties.getApiKey() == null || properties.getApiKey().isBlank())) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未配置,请先设置 DEEPSEEK_API_KEY 或个人 AI API Key\");\n }\n }\n\n private String resolvePersonalAiApiKey(CurrentUser currentUser) {\n return userSettingsService.resolveAiApiKey(currentUser.id());\n }\n\n private MailAiAnalysisResponse parseAnalysis(String modelJson) throws JsonProcessingException {\n return objectMapper.readValue(modelJson, MailAiAnalysisResponse.class);\n }\n\n private MailAiAnalysisResponse withMetadata(MailAiAnalysisResponse parsed,\n LocalDateTime analyzedAt,\n String replyInstruction,\n SuggestedResponse customSuggestedResponse) {\n return new MailAiAnalysisResponse(\n parsed.emailType(),\n parsed.brief(),\n parsed.keyPoints(),\n parsed.senderIntent(),\n parsed.tone(),\n parsed.actionItems(),\n parsed.risks(),\n parsed.nextStep(),\n parsed.suggestedResponse(),\n properties.getModel(),\n analyzedAt,\n replyInstruction,\n customSuggestedResponse\n );\n }\n\n private MailAiAnalysisResponse toResponse(MailAiAnalysis analysis) {\n try {\n MailAiAnalysisResponse parsed = objectMapper.readValue(analysis.getAnalysisJson(), MailAiAnalysisResponse.class);\n SuggestedResponse customSuggestedResponse = analysis.getCustomSuggestedResponseJson() == null || analysis.getCustomSuggestedResponseJson().isBlank()\n ? null\n : objectMapper.readValue(analysis.getCustomSuggestedResponseJson(), SuggestedResponse.class);\n return new MailAiAnalysisResponse(\n parsed.emailType(),\n parsed.brief(),\n parsed.keyPoints(),\n parsed.senderIntent(),\n parsed.tone(),\n parsed.actionItems(),\n parsed.risks(),\n parsed.nextStep(),\n parsed.suggestedResponse(),\n analysis.getModel(),\n analysis.getAnalyzedAt(),\n analysis.getReplyInstruction(),\n customSuggestedResponse\n );\n } catch (JsonProcessingException ex) {\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 分析缓存格式异常\");\n }\n }\n\n private MailAiAnalysis createAnalysisSnapshot(Long accountId, Long messageId, MailAiAnalysisResponse response) {\n MailAiAnalysis analysis = new MailAiAnalysis();\n analysis.setAccountId(accountId);\n analysis.setMessageId(messageId);\n analysis.setModel(response.model());\n try {\n analysis.setAnalysisJson(objectMapper.writeValueAsString(response));\n } catch (JsonProcessingException ex) {\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 分析缓存格式异常\");\n }\n analysis.setAnalyzedAt(response.analyzedAt());\n return analysis;\n }\n\n private String buildReplyPrompt(MailMessage mailMessage, MailAiAnalysisResponse analysis, String instruction) throws JsonProcessingException {\n BodyInput bodyInput = buildBodyInput(mailMessage);\n return \"\"\"\n 请为下面这封邮件生成建议回复 JSON。\n\n 输出格式:\n {\n \"required\": true,\n \"reason\": \"是否需要回复以及原因\",\n \"strategy\": \"回复策略\",\n \"draft\": \"可直接放入发信正文的纯文本回复草稿,如果无需回复则为空字符串\"\n }\n\n 用户额外要求:%s\n\n 已有分析:\n %s\n\n 邮件信息:\n 主题:%s\n 发件人:%s\n 收件人:%s\n 正文:\n %s\n \"\"\".formatted(\n instruction.isBlank() ? \"无\" : instruction,\n objectMapper.writeValueAsString(analysis),\n safe(mailMessage.getSubject()),\n safe(mailMessage.getFromAddress()),\n safe(mailMessage.getToAddresses()),\n bodyInput.body()\n );\n }\n\n private String elapsedSeconds(long startedAt) {\n return String.format(\"%.2f\", (System.nanoTime() - startedAt) / 1_000_000_000.0);\n }\n\n private String safe(Object value) {\n return value == null ? \"\" : value.toString();\n }\n\n private record BodyInput(String body, boolean truncated) {\n }\n}\n"}, "90d0a51f501ee4e0ce67097b1e8d6e8da5f8d452": {"t": 1785751006.8758984, "n": 16815, "h": "fb736231ed0efe6a", "c": "package com.mailnest.mailai;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.mailnest.common.ResourceNotFoundException;\nimport com.mailnest.mailaccount.MailAccountMapper;\nimport com.mailnest.mailai.dto.MailAiAnalysisResponse;\nimport com.mailnest.mailai.dto.SuggestedReplyRequest;\nimport com.mailnest.mailai.dto.SuggestedResponse;\nimport com.mailnest.mailmessage.MailMessage;\nimport com.mailnest.mailmessage.MailMessageMapper;\nimport com.mailnest.security.CurrentUser;\nimport com.mailnest.settings.UserSettingsService;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.stereotype.Service;\n\nimport java.time.LocalDateTime;\n\n@Service\npublic class MailAiService {\n\n private static final Logger log = LoggerFactory.getLogger(MailAiService.class);\n\n private static final String SYSTEM_PROMPT = \"\"\"\n 你是 MailNest 的邮件分析助手,服务对象是需要快速理解和处理邮件的普通用户。\n\n 你的任务不是泛泛总结邮件,而是帮助用户判断:\n - 这封邮件属于什么类型;\n - 发件人的主要意图是什么;\n - 邮件有哪些关键重点;\n - 是否有明确待处理事项、截止时间或优先级;\n - 语气是否中性、友好、催促、紧急、可疑或存在情绪压力;\n - 用户下一步应该如何处理。\n\n 你必须遵守:\n 1. 只基于用户提供的邮件内容分析,不要编造背景、关系或事实。\n 2. 如果邮件内容不足以判断,请明确说明“不确定”。\n 3. 对“情绪操控、施压、可疑风险”等判断必须谨慎,只能作为可能信号,不能下绝对结论。\n 4. 如果指出语气或风险信号,必须给出邮件文本中的依据。\n 5. 输出应帮助用户冷静、专业、高效地处理邮件,不要激化冲突。\n 6. 使用简体中文。\n 7. 只输出合法 JSON,不要输出 Markdown,不要添加额外解释。\n \"\"\";\n\n private static final String REPLY_SYSTEM_PROMPT = \"\"\"\n 你是 MailNest 的邮件回复助手。请基于邮件内容、已有分析和用户额外要求,生成一段适合直接复制修改的中文回复建议。\n\n 你必须遵守:\n 1. 只基于邮件和用户要求生成回复,不要编造事实、承诺、时间或关系。\n 2. 如果邮件是广告、验证码、系统通知、账单提醒等通常无需回复的类型,应明确 required=false,并说明原因。\n 3. 如果用户仍然要求回复,可以生成礼貌、克制、低风险的回复草稿。\n 4. 如果用户要求与邮件事实冲突,请用稳妥表达,不要替用户作出无法确认的承诺。\n 5. 只输出合法 JSON,不要输出 Markdown,不要添加额外解释。\n \"\"\";\n\n private final MailAccountMapper mailAccountMapper;\n private final MailMessageMapper mailMessageMapper;\n private final MailAiAnalysisMapper mailAiAnalysisMapper;\n private final AiChatClient aiChatClient;\n private final MailAiProperties properties;\n private final UserSettingsService userSettingsService;\n private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();\n\n public MailAiService(MailAccountMapper mailAccountMapper,\n MailMessageMapper mailMessageMapper,\n MailAiAnalysisMapper mailAiAnalysisMapper,\n AiChatClient aiChatClient,\n MailAiProperties properties,\n UserSettingsService userSettingsService) {\n this.mailAccountMapper = mailAccountMapper;\n this.mailMessageMapper = mailMessageMapper;\n this.mailAiAnalysisMapper = mailAiAnalysisMapper;\n this.aiChatClient = aiChatClient;\n this.properties = properties;\n this.userSettingsService = userSettingsService;\n }\n\n public MailAiAnalysisResponse getAnalysis(CurrentUser currentUser, Long accountId, Long messageId) {\n validateMessageAccess(currentUser, accountId, messageId);\n return mailAiAnalysisMapper.findByAccountIdAndMessageId(accountId, messageId)\n .map(this::toResponse)\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 分析结果不存在\"));\n }\n\n public MailAiAnalysisResponse analyze(CurrentUser currentUser, Long accountId, Long messageId) {\n String apiKeyOverride = resolvePersonalAiApiKey(currentUser);\n ensureAiConfigured(apiKeyOverride);\n MailMessage mailMessage = validateMessageAccess(currentUser, accountId, messageId);\n MailAiAnalysis cached = mailAiAnalysisMapper.findByAccountIdAndMessageId(accountId, messageId).orElse(null);\n if (cached != null) {\n return toResponse(cached);\n }\n long startedAt = System.nanoTime();\n log.info(\"Mail AI analysis started accountId={}, messageId={}, model={}\", accountId, messageId, properties.getModel());\n try {\n String modelJson = aiChatClient.completeJson(SYSTEM_PROMPT, buildUserPrompt(mailMessage), apiKeyOverride);\n MailAiAnalysisResponse parsed = parseAnalysis(modelJson);\n LocalDateTime analyzedAt = LocalDateTime.now();\n MailAiAnalysisResponse response = withMetadata(parsed, analyzedAt, null, null);\n MailAiAnalysis analysis = new MailAiAnalysis();\n analysis.setAccountId(accountId);\n analysis.setMessageId(messageId);\n analysis.setModel(properties.getModel());\n analysis.setAnalysisJson(objectMapper.writeValueAsString(response));\n analysis.setAnalyzedAt(analyzedAt);\n mailAiAnalysisMapper.insert(analysis);\n log.info(\"Mail AI analysis completed accountId={}, messageId={}, model={}, cached=false, time={}s\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt));\n return response;\n } catch (JsonProcessingException ex) {\n log.warn(\"Mail AI analysis invalid JSON accountId={}, messageId={}, model={}, time={}s\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt));\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n } catch (RuntimeException ex) {\n log.warn(\"Mail AI analysis failed accountId={}, messageId={}, model={}, time={}s, reason={}\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt), ex.getMessage());\n throw ex;\n }\n }\n\n public SuggestedResponse generateSuggestedReply(CurrentUser currentUser, Long accountId, Long messageId, SuggestedReplyRequest request) {\n String apiKeyOverride = resolvePersonalAiApiKey(currentUser);\n ensureAiConfigured(apiKeyOverride);\n MailMessage mailMessage = validateMessageAccess(currentUser, accountId, messageId);\n MailAiAnalysis analysis = mailAiAnalysisMapper.findByAccountIdAndMessageId(accountId, messageId)\n .orElseGet(() -> createAnalysisSnapshot(accountId, messageId, analyze(currentUser, accountId, messageId)));\n String instruction = request == null ? \"\" : safe(request.userInstruction()).trim();\n long startedAt = System.nanoTime();\n log.info(\"Mail AI suggested reply started accountId={}, messageId={}, model={}\", accountId, messageId, properties.getModel());\n try {\n SuggestedResponse suggestedResponse = objectMapper.readValue(\n aiChatClient.completeJson(REPLY_SYSTEM_PROMPT, buildReplyPrompt(mailMessage, toResponse(analysis), instruction), apiKeyOverride),\n SuggestedResponse.class\n );\n analysis.setReplyInstruction(instruction);\n analysis.setCustomSuggestedResponseJson(objectMapper.writeValueAsString(suggestedResponse));\n mailAiAnalysisMapper.updateSuggestedResponse(analysis);\n log.info(\"Mail AI suggested reply completed accountId={}, messageId={}, model={}, time={}s\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt));\n return suggestedResponse;\n } catch (JsonProcessingException ex) {\n log.warn(\"Mail AI suggested reply invalid JSON accountId={}, messageId={}, model={}, time={}s\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt));\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n } catch (RuntimeException ex) {\n log.warn(\"Mail AI suggested reply failed accountId={}, messageId={}, model={}, time={}s, reason={}\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt), ex.getMessage());\n throw ex;\n }\n }\n\n private String buildUserPrompt(MailMessage mailMessage) {\n BodyInput bodyInput = buildBodyInput(mailMessage);\n return \"\"\"\n 请分析下面这封邮件,并输出结构化 JSON。\n\n 输出格式:\n {\n \"emailType\": \"WORK | PERSONAL | NOTIFICATION | BILL | VERIFICATION | MARKETING | SYSTEM | RISK | OTHER | UNKNOWN\",\n \"brief\": \"一句话概括邮件核心内容,最多 100 个中文字符\",\n \"keyPoints\": [\"关键点 1\", \"关键点 2\"],\n \"senderIntent\": {\n \"type\": \"INFORM_ONLY | REQUEST_ACTION | REQUEST_REPLY | DELEGATE_TASK | FOLLOW_UP | FEEDBACK | ENCOURAGEMENT | WARNING | PRESSURE | NEGOTIATION | PROMOTION | VERIFY_IDENTITY | PAYMENT_RELATED | UNKNOWN\",\n \"label\": \"中文标签\",\n \"explanation\": \"简要解释发件人意图\"\n },\n \"tone\": {\n \"type\": \"NEUTRAL | FRIENDLY | ENCOURAGING | FORMAL | URGENT | PRESSURING | CRITICAL | PASSIVE_AGGRESSIVE | MANIPULATIVE | SUSPICIOUS | UNCLEAR\",\n \"label\": \"中文标签\",\n \"confidence\": \"LOW | MEDIUM | HIGH\",\n \"signals\": [\"判断依据 1\", \"判断依据 2\"],\n \"caution\": \"谨慎提示,如果没有特殊风险则说明仅供参考\"\n },\n \"actionItems\": [\n {\n \"task\": \"需要处理的事项\",\n \"deadline\": \"截止时间,没有则写“不明确”\",\n \"priority\": \"LOW | MEDIUM | HIGH | URGENT | UNKNOWN\",\n \"evidence\": \"邮件中的文本依据\"\n }\n ],\n \"risks\": [\"风险或注意点,没有则返回空数组\"],\n \"nextStep\": \"建议下一步操作,最多 120 个中文字符\",\n \"suggestedResponse\": {\n \"required\": true,\n \"reason\": \"是否需要回复以及原因。广告、验证码、系统通知通常不需要回复\",\n \"strategy\": \"建议回复策略,没有必要回复则说明无需回复\",\n \"draft\": \"一段简短回复草稿,如果无需回复则为空字符串\"\n }\n }\n\n 注意:\n - 不要编造邮件中没有的截止时间、任务或关系。\n - 如果没有待处理事项,actionItems 返回空数组。\n - 如果没有明显风险,risks 返回空数组。\n - 如果语气正常,不要强行指出负面含义。\n - 如果可能存在情绪压力或可疑风险,请给出具体文本依据,并保持谨慎表述。\n %s\n\n 邮件信息:\n 主题:%s\n 发件人:%s\n 收件人:%s\n 发送时间:%s\n 接收时间:%s\n 摘要:%s\n 正文:\n %s\n \"\"\".formatted(\n bodyInput.truncated() ? \"- 正文因长度限制已截断,请只基于可见内容判断。\" : \"\",\n safe(mailMessage.getSubject()),\n safe(mailMessage.getFromAddress()),\n safe(mailMessage.getToAddresses()),\n safe(mailMessage.getSentAt()),\n safe(mailMessage.getReceivedAt()),\n safe(mailMessage.getSnippet()),\n bodyInput.body()\n );\n }\n\n private BodyInput buildBodyInput(MailMessage mailMessage) {\n String body = mailMessage.getTextBody();\n if (body == null || body.isBlank()) {\n body = stripHtml(mailMessage.getHtmlBody());\n }\n body = safe(body);\n int maxChars = Math.max(1000, properties.getMaxInputChars());\n if (body.length() <= maxChars) {\n return new BodyInput(body, false);\n }\n return new BodyInput(body.substring(0, maxChars), true);\n }\n\n private String stripHtml(String html) {\n if (html == null || html.isBlank()) {\n return \"\";\n }\n return html.replaceAll(\"<[^>]*>\", \" \").replaceAll(\"\\\\s+\", \" \").trim();\n }\n\n private MailMessage validateMessageAccess(CurrentUser currentUser, Long accountId, Long messageId) {\n mailAccountMapper.findByIdAndUserId(accountId, currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"邮箱账号不存在\"));\n return mailMessageMapper.findByIdAndAccountId(messageId, accountId)\n .orElseThrow(() -> new ResourceNotFoundException(\"邮件不存在\"));\n }\n\n private void ensureAiConfigured(String apiKeyOverride) {\n if (!properties.isEnabled()) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未启用\");\n }\n if ((apiKeyOverride == null || apiKeyOverride.isBlank()) && (properties.getApiKey() == null || properties.getApiKey().isBlank())) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未配置,请先设置 DEEPSEEK_API_KEY 或个人 AI API Key\");\n }\n }\n\n private String resolvePersonalAiApiKey(CurrentUser currentUser) {\n return userSettingsService.resolveAiApiKey(currentUser.id());\n }\n\n private MailAiAnalysisResponse parseAnalysis(String modelJson) throws JsonProcessingException {\n return objectMapper.readValue(modelJson, MailAiAnalysisResponse.class);\n }\n\n private MailAiAnalysisResponse withMetadata(MailAiAnalysisResponse parsed,\n LocalDateTime analyzedAt,\n String replyInstruction,\n SuggestedResponse customSuggestedResponse) {\n return new MailAiAnalysisResponse(\n parsed.emailType(),\n parsed.brief(),\n parsed.keyPoints(),\n parsed.senderIntent(),\n parsed.tone(),\n parsed.actionItems(),\n parsed.risks(),\n parsed.nextStep(),\n parsed.suggestedResponse(),\n properties.getModel(),\n analyzedAt,\n replyInstruction,\n customSuggestedResponse\n );\n }\n\n private MailAiAnalysisResponse toResponse(MailAiAnalysis analysis) {\n try {\n MailAiAnalysisResponse parsed = objectMapper.readValue(analysis.getAnalysisJson(), MailAiAnalysisResponse.class);\n SuggestedResponse customSuggestedResponse = analysis.getCustomSuggestedResponseJson() == null || analysis.getCustomSuggestedResponseJson().isBlank()\n ? null\n : objectMapper.readValue(analysis.getCustomSuggestedResponseJson(), SuggestedResponse.class);\n return new MailAiAnalysisResponse(\n parsed.emailType(),\n parsed.brief(),\n parsed.keyPoints(),\n parsed.senderIntent(),\n parsed.tone(),\n parsed.actionItems(),\n parsed.risks(),\n parsed.nextStep(),\n parsed.suggestedResponse(),\n analysis.getModel(),\n analysis.getAnalyzedAt(),\n analysis.getReplyInstruction(),\n customSuggestedResponse\n );\n } catch (JsonProcessingException ex) {\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 分析缓存格式异常\");\n }\n }\n\n private MailAiAnalysis createAnalysisSnapshot(Long accountId, Long messageId, MailAiAnalysisResponse response) {\n MailAiAnalysis analysis = new MailAiAnalysis();\n analysis.setAccountId(accountId);\n analysis.setMessageId(messageId);\n analysis.setModel(response.model());\n try {\n analysis.setAnalysisJson(objectMapper.writeValueAsString(response));\n } catch (JsonProcessingException ex) {\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 分析缓存格式异常\");\n }\n analysis.setAnalyzedAt(response.analyzedAt());\n return analysis;\n }\n\n private String buildReplyPrompt(MailMessage mailMessage, MailAiAnalysisResponse analysis, String instruction) throws JsonProcessingException {\n BodyInput bodyInput = buildBodyInput(mailMessage);\n return \"\"\"\n 请为下面这封邮件生成建议回复 JSON。\n\n 输出格式:\n {\n \"required\": true,\n \"reason\": \"是否需要回复以及原因\",\n \"strategy\": \"回复策略\",\n \"draft\": \"回复草稿,如果无需回复则为空字符串\"\n }\n\n 用户额外要求:%s\n\n 已有分析:\n %s\n\n 邮件信息:\n 主题:%s\n 发件人:%s\n 收件人:%s\n 正文:\n %s\n \"\"\".formatted(\n instruction.isBlank() ? \"无\" : instruction,\n objectMapper.writeValueAsString(analysis),\n safe(mailMessage.getSubject()),\n safe(mailMessage.getFromAddress()),\n safe(mailMessage.getToAddresses()),\n bodyInput.body()\n );\n }\n\n private String elapsedSeconds(long startedAt) {\n return String.format(\"%.2f\", (System.nanoTime() - startedAt) / 1_000_000_000.0);\n }\n\n private String safe(Object value) {\n return value == null ? \"\" : value.toString();\n }\n\n private record BodyInput(String body, boolean truncated) {\n }\n}\n"}, "da949067750b48e336bd71fe108866f39356b964": {"t": 1785751007.9791105, "n": 16143, "h": "40b52a21fd0e3e93", "c": "package com.mailnest.mailai;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.mailnest.common.ResourceNotFoundException;\nimport com.mailnest.mailaccount.MailAccountMapper;\nimport com.mailnest.mailai.dto.MailAiAnalysisResponse;\nimport com.mailnest.mailai.dto.SuggestedReplyRequest;\nimport com.mailnest.mailai.dto.SuggestedResponse;\nimport com.mailnest.mailmessage.MailMessage;\nimport com.mailnest.mailmessage.MailMessageMapper;\nimport com.mailnest.security.CurrentUser;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.stereotype.Service;\n\nimport java.time.LocalDateTime;\n\n@Service\npublic class MailAiService {\n\n private static final Logger log = LoggerFactory.getLogger(MailAiService.class);\n\n private static final String SYSTEM_PROMPT = \"\"\"\n 你是 MailNest 的邮件分析助手,服务对象是需要快速理解和处理邮件的普通用户。\n\n 你的任务不是泛泛总结邮件,而是帮助用户判断:\n - 这封邮件属于什么类型;\n - 发件人的主要意图是什么;\n - 邮件有哪些关键重点;\n - 是否有明确待处理事项、截止时间或优先级;\n - 语气是否中性、友好、催促、紧急、可疑或存在情绪压力;\n - 用户下一步应该如何处理。\n\n 你必须遵守:\n 1. 只基于用户提供的邮件内容分析,不要编造背景、关系或事实。\n 2. 如果邮件内容不足以判断,请明确说明“不确定”。\n 3. 对“情绪操控、施压、可疑风险”等判断必须谨慎,只能作为可能信号,不能下绝对结论。\n 4. 如果指出语气或风险信号,必须给出邮件文本中的依据。\n 5. 输出应帮助用户冷静、专业、高效地处理邮件,不要激化冲突。\n 6. 使用简体中文。\n 7. 只输出合法 JSON,不要输出 Markdown,不要添加额外解释。\n \"\"\";\n\n private static final String REPLY_SYSTEM_PROMPT = \"\"\"\n 你是 MailNest 的邮件回复助手。请基于邮件内容、已有分析和用户额外要求,生成一段适合直接复制修改的中文回复建议。\n\n 你必须遵守:\n 1. 只基于邮件和用户要求生成回复,不要编造事实、承诺、时间或关系。\n 2. 如果邮件是广告、验证码、系统通知、账单提醒等通常无需回复的类型,应明确 required=false,并说明原因。\n 3. 如果用户仍然要求回复,可以生成礼貌、克制、低风险的回复草稿。\n 4. 如果用户要求与邮件事实冲突,请用稳妥表达,不要替用户作出无法确认的承诺。\n 5. 只输出合法 JSON,不要输出 Markdown,不要添加额外解释。\n \"\"\";\n\n private final MailAccountMapper mailAccountMapper;\n private final MailMessageMapper mailMessageMapper;\n private final MailAiAnalysisMapper mailAiAnalysisMapper;\n private final AiChatClient aiChatClient;\n private final MailAiProperties properties;\n private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();\n\n public MailAiService(MailAccountMapper mailAccountMapper,\n MailMessageMapper mailMessageMapper,\n MailAiAnalysisMapper mailAiAnalysisMapper,\n AiChatClient aiChatClient,\n MailAiProperties properties) {\n this.mailAccountMapper = mailAccountMapper;\n this.mailMessageMapper = mailMessageMapper;\n this.mailAiAnalysisMapper = mailAiAnalysisMapper;\n this.aiChatClient = aiChatClient;\n this.properties = properties;\n }\n\n public MailAiAnalysisResponse getAnalysis(CurrentUser currentUser, Long accountId, Long messageId) {\n validateMessageAccess(currentUser, accountId, messageId);\n return mailAiAnalysisMapper.findByAccountIdAndMessageId(accountId, messageId)\n .map(this::toResponse)\n .orElseThrow(() -> new ResourceNotFoundException(\"AI 分析结果不存在\"));\n }\n\n public MailAiAnalysisResponse analyze(CurrentUser currentUser, Long accountId, Long messageId) {\n ensureAiConfigured();\n MailMessage mailMessage = validateMessageAccess(currentUser, accountId, messageId);\n MailAiAnalysis cached = mailAiAnalysisMapper.findByAccountIdAndMessageId(accountId, messageId).orElse(null);\n if (cached != null) {\n return toResponse(cached);\n }\n long startedAt = System.nanoTime();\n log.info(\"Mail AI analysis started accountId={}, messageId={}, model={}\", accountId, messageId, properties.getModel());\n try {\n String modelJson = aiChatClient.completeJson(SYSTEM_PROMPT, buildUserPrompt(mailMessage));\n MailAiAnalysisResponse parsed = parseAnalysis(modelJson);\n LocalDateTime analyzedAt = LocalDateTime.now();\n MailAiAnalysisResponse response = withMetadata(parsed, analyzedAt, null, null);\n MailAiAnalysis analysis = new MailAiAnalysis();\n analysis.setAccountId(accountId);\n analysis.setMessageId(messageId);\n analysis.setModel(properties.getModel());\n analysis.setAnalysisJson(objectMapper.writeValueAsString(response));\n analysis.setAnalyzedAt(analyzedAt);\n mailAiAnalysisMapper.insert(analysis);\n log.info(\"Mail AI analysis completed accountId={}, messageId={}, model={}, cached=false, time={}s\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt));\n return response;\n } catch (JsonProcessingException ex) {\n log.warn(\"Mail AI analysis invalid JSON accountId={}, messageId={}, model={}, time={}s\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt));\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n } catch (RuntimeException ex) {\n log.warn(\"Mail AI analysis failed accountId={}, messageId={}, model={}, time={}s, reason={}\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt), ex.getMessage());\n throw ex;\n }\n }\n\n public SuggestedResponse generateSuggestedReply(CurrentUser currentUser, Long accountId, Long messageId, SuggestedReplyRequest request) {\n ensureAiConfigured();\n MailMessage mailMessage = validateMessageAccess(currentUser, accountId, messageId);\n MailAiAnalysis analysis = mailAiAnalysisMapper.findByAccountIdAndMessageId(accountId, messageId)\n .orElseGet(() -> createAnalysisSnapshot(accountId, messageId, analyze(currentUser, accountId, messageId)));\n String instruction = request == null ? \"\" : safe(request.userInstruction()).trim();\n long startedAt = System.nanoTime();\n log.info(\"Mail AI suggested reply started accountId={}, messageId={}, model={}\", accountId, messageId, properties.getModel());\n try {\n SuggestedResponse suggestedResponse = objectMapper.readValue(\n aiChatClient.completeJson(REPLY_SYSTEM_PROMPT, buildReplyPrompt(mailMessage, toResponse(analysis), instruction)),\n SuggestedResponse.class\n );\n analysis.setReplyInstruction(instruction);\n analysis.setCustomSuggestedResponseJson(objectMapper.writeValueAsString(suggestedResponse));\n mailAiAnalysisMapper.updateSuggestedResponse(analysis);\n log.info(\"Mail AI suggested reply completed accountId={}, messageId={}, model={}, time={}s\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt));\n return suggestedResponse;\n } catch (JsonProcessingException ex) {\n log.warn(\"Mail AI suggested reply invalid JSON accountId={}, messageId={}, model={}, time={}s\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt));\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n } catch (RuntimeException ex) {\n log.warn(\"Mail AI suggested reply failed accountId={}, messageId={}, model={}, time={}s, reason={}\",\n accountId, messageId, properties.getModel(), elapsedSeconds(startedAt), ex.getMessage());\n throw ex;\n }\n }\n\n private String buildUserPrompt(MailMessage mailMessage) {\n BodyInput bodyInput = buildBodyInput(mailMessage);\n return \"\"\"\n 请分析下面这封邮件,并输出结构化 JSON。\n\n 输出格式:\n {\n \"emailType\": \"WORK | PERSONAL | NOTIFICATION | BILL | VERIFICATION | MARKETING | SYSTEM | RISK | OTHER | UNKNOWN\",\n \"brief\": \"一句话概括邮件核心内容,最多 100 个中文字符\",\n \"keyPoints\": [\"关键点 1\", \"关键点 2\"],\n \"senderIntent\": {\n \"type\": \"INFORM_ONLY | REQUEST_ACTION | REQUEST_REPLY | DELEGATE_TASK | FOLLOW_UP | FEEDBACK | ENCOURAGEMENT | WARNING | PRESSURE | NEGOTIATION | PROMOTION | VERIFY_IDENTITY | PAYMENT_RELATED | UNKNOWN\",\n \"label\": \"中文标签\",\n \"explanation\": \"简要解释发件人意图\"\n },\n \"tone\": {\n \"type\": \"NEUTRAL | FRIENDLY | ENCOURAGING | FORMAL | URGENT | PRESSURING | CRITICAL | PASSIVE_AGGRESSIVE | MANIPULATIVE | SUSPICIOUS | UNCLEAR\",\n \"label\": \"中文标签\",\n \"confidence\": \"LOW | MEDIUM | HIGH\",\n \"signals\": [\"判断依据 1\", \"判断依据 2\"],\n \"caution\": \"谨慎提示,如果没有特殊风险则说明仅供参考\"\n },\n \"actionItems\": [\n {\n \"task\": \"需要处理的事项\",\n \"deadline\": \"截止时间,没有则写“不明确”\",\n \"priority\": \"LOW | MEDIUM | HIGH | URGENT | UNKNOWN\",\n \"evidence\": \"邮件中的文本依据\"\n }\n ],\n \"risks\": [\"风险或注意点,没有则返回空数组\"],\n \"nextStep\": \"建议下一步操作,最多 120 个中文字符\",\n \"suggestedResponse\": {\n \"required\": true,\n \"reason\": \"是否需要回复以及原因。广告、验证码、系统通知通常不需要回复\",\n \"strategy\": \"建议回复策略,没有必要回复则说明无需回复\",\n \"draft\": \"一段简短回复草稿,如果无需回复则为空字符串\"\n }\n }\n\n 注意:\n - 不要编造邮件中没有的截止时间、任务或关系。\n - 如果没有待处理事项,actionItems 返回空数组。\n - 如果没有明显风险,risks 返回空数组。\n - 如果语气正常,不要强行指出负面含义。\n - 如果可能存在情绪压力或可疑风险,请给出具体文本依据,并保持谨慎表述。\n %s\n\n 邮件信息:\n 主题:%s\n 发件人:%s\n 收件人:%s\n 发送时间:%s\n 接收时间:%s\n 摘要:%s\n 正文:\n %s\n \"\"\".formatted(\n bodyInput.truncated() ? \"- 正文因长度限制已截断,请只基于可见内容判断。\" : \"\",\n safe(mailMessage.getSubject()),\n safe(mailMessage.getFromAddress()),\n safe(mailMessage.getToAddresses()),\n safe(mailMessage.getSentAt()),\n safe(mailMessage.getReceivedAt()),\n safe(mailMessage.getSnippet()),\n bodyInput.body()\n );\n }\n\n private BodyInput buildBodyInput(MailMessage mailMessage) {\n String body = mailMessage.getTextBody();\n if (body == null || body.isBlank()) {\n body = stripHtml(mailMessage.getHtmlBody());\n }\n body = safe(body);\n int maxChars = Math.max(1000, properties.getMaxInputChars());\n if (body.length() <= maxChars) {\n return new BodyInput(body, false);\n }\n return new BodyInput(body.substring(0, maxChars), true);\n }\n\n private String stripHtml(String html) {\n if (html == null || html.isBlank()) {\n return \"\";\n }\n return html.replaceAll(\"<[^>]*>\", \" \").replaceAll(\"\\\\s+\", \" \").trim();\n }\n\n private MailMessage validateMessageAccess(CurrentUser currentUser, Long accountId, Long messageId) {\n mailAccountMapper.findByIdAndUserId(accountId, currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"邮箱账号不存在\"));\n return mailMessageMapper.findByIdAndAccountId(messageId, accountId)\n .orElseThrow(() -> new ResourceNotFoundException(\"邮件不存在\"));\n }\n\n private void ensureAiConfigured() {\n if (!properties.isEnabled()) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未启用\");\n }\n if (properties.getApiKey() == null || properties.getApiKey().isBlank()) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未配置,请先设置 DEEPSEEK_API_KEY\");\n }\n }\n\n private MailAiAnalysisResponse parseAnalysis(String modelJson) throws JsonProcessingException {\n return objectMapper.readValue(modelJson, MailAiAnalysisResponse.class);\n }\n\n private MailAiAnalysisResponse withMetadata(MailAiAnalysisResponse parsed,\n LocalDateTime analyzedAt,\n String replyInstruction,\n SuggestedResponse customSuggestedResponse) {\n return new MailAiAnalysisResponse(\n parsed.emailType(),\n parsed.brief(),\n parsed.keyPoints(),\n parsed.senderIntent(),\n parsed.tone(),\n parsed.actionItems(),\n parsed.risks(),\n parsed.nextStep(),\n parsed.suggestedResponse(),\n properties.getModel(),\n analyzedAt,\n replyInstruction,\n customSuggestedResponse\n );\n }\n\n private MailAiAnalysisResponse toResponse(MailAiAnalysis analysis) {\n try {\n MailAiAnalysisResponse parsed = objectMapper.readValue(analysis.getAnalysisJson(), MailAiAnalysisResponse.class);\n SuggestedResponse customSuggestedResponse = analysis.getCustomSuggestedResponseJson() == null || analysis.getCustomSuggestedResponseJson().isBlank()\n ? null\n : objectMapper.readValue(analysis.getCustomSuggestedResponseJson(), SuggestedResponse.class);\n return new MailAiAnalysisResponse(\n parsed.emailType(),\n parsed.brief(),\n parsed.keyPoints(),\n parsed.senderIntent(),\n parsed.tone(),\n parsed.actionItems(),\n parsed.risks(),\n parsed.nextStep(),\n parsed.suggestedResponse(),\n analysis.getModel(),\n analysis.getAnalyzedAt(),\n analysis.getReplyInstruction(),\n customSuggestedResponse\n );\n } catch (JsonProcessingException ex) {\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 分析缓存格式异常\");\n }\n }\n\n private MailAiAnalysis createAnalysisSnapshot(Long accountId, Long messageId, MailAiAnalysisResponse response) {\n MailAiAnalysis analysis = new MailAiAnalysis();\n analysis.setAccountId(accountId);\n analysis.setMessageId(messageId);\n analysis.setModel(response.model());\n try {\n analysis.setAnalysisJson(objectMapper.writeValueAsString(response));\n } catch (JsonProcessingException ex) {\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 分析缓存格式异常\");\n }\n analysis.setAnalyzedAt(response.analyzedAt());\n return analysis;\n }\n\n private String buildReplyPrompt(MailMessage mailMessage, MailAiAnalysisResponse analysis, String instruction) throws JsonProcessingException {\n BodyInput bodyInput = buildBodyInput(mailMessage);\n return \"\"\"\n 请为下面这封邮件生成建议回复 JSON。\n\n 输出格式:\n {\n \"required\": true,\n \"reason\": \"是否需要回复以及原因\",\n \"strategy\": \"回复策略\",\n \"draft\": \"回复草稿,如果无需回复则为空字符串\"\n }\n\n 用户额外要求:%s\n\n 已有分析:\n %s\n\n 邮件信息:\n 主题:%s\n 发件人:%s\n 收件人:%s\n 正文:\n %s\n \"\"\".formatted(\n instruction.isBlank() ? \"无\" : instruction,\n objectMapper.writeValueAsString(analysis),\n safe(mailMessage.getSubject()),\n safe(mailMessage.getFromAddress()),\n safe(mailMessage.getToAddresses()),\n bodyInput.body()\n );\n }\n\n private String elapsedSeconds(long startedAt) {\n return String.format(\"%.2f\", (System.nanoTime() - startedAt) / 1_000_000_000.0);\n }\n\n private String safe(Object value) {\n return value == null ? \"\" : value.toString();\n }\n\n private record BodyInput(String body, boolean truncated) {\n }\n}\n"}, "a38a9a666ca7e8f251970a2a82f7e83d7832828c": {"t": 1785751009.1092722, "n": 7199, "h": "869bd7c970f02dd0", "c": "package com.mailnest.mailai;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.mailnest.common.ResourceNotFoundException;\nimport com.mailnest.mailaccount.MailAccountMapper;\nimport com.mailnest.mailai.dto.MailAiAnalysisResponse;\nimport com.mailnest.mailmessage.MailMessage;\nimport com.mailnest.mailmessage.MailMessageMapper;\nimport com.mailnest.security.CurrentUser;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.stereotype.Service;\n\nimport java.time.LocalDateTime;\n\n@Service\npublic class MailAiService {\n\n private static final String SYSTEM_PROMPT = \"\"\"\n 你是 MailNest 的邮件分析助手,服务对象是需要快速理解和处理邮件的普通用户。\n\n 你的任务不是泛泛总结邮件,而是帮助用户判断:\n - 这封邮件属于什么类型;\n - 发件人的主要意图是什么;\n - 邮件有哪些关键重点;\n - 是否有明确待处理事项、截止时间或优先级;\n - 语气是否中性、友好、催促、紧急、可疑或存在情绪压力;\n - 用户下一步应该如何处理。\n\n 你必须遵守:\n 1. 只基于用户提供的邮件内容分析,不要编造背景、关系或事实。\n 2. 如果邮件内容不足以判断,请明确说明“不确定”。\n 3. 对“情绪操控、施压、可疑风险”等判断必须谨慎,只能作为可能信号,不能下绝对结论。\n 4. 如果指出语气或风险信号,必须给出邮件文本中的依据。\n 5. 输出应帮助用户冷静、专业、高效地处理邮件,不要激化冲突。\n 6. 使用简体中文。\n 7. 只输出合法 JSON,不要输出 Markdown,不要添加额外解释。\n \"\"\";\n\n private final MailAccountMapper mailAccountMapper;\n private final MailMessageMapper mailMessageMapper;\n private final AiChatClient aiChatClient;\n private final MailAiProperties properties;\n private final ObjectMapper objectMapper = new ObjectMapper();\n\n public MailAiService(MailAccountMapper mailAccountMapper,\n MailMessageMapper mailMessageMapper,\n AiChatClient aiChatClient,\n MailAiProperties properties) {\n this.mailAccountMapper = mailAccountMapper;\n this.mailMessageMapper = mailMessageMapper;\n this.aiChatClient = aiChatClient;\n this.properties = properties;\n }\n\n public MailAiAnalysisResponse analyze(CurrentUser currentUser, Long accountId, Long messageId) {\n if (!properties.isEnabled()) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未启用\");\n }\n if (properties.getApiKey() == null || properties.getApiKey().isBlank()) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未配置,请先设置 DEEPSEEK_API_KEY\");\n }\n mailAccountMapper.findByIdAndUserId(accountId, currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"邮箱账号不存在\"));\n MailMessage mailMessage = mailMessageMapper.findByIdAndAccountId(messageId, accountId)\n .orElseThrow(() -> new ResourceNotFoundException(\"邮件不存在\"));\n String modelJson = aiChatClient.completeJson(SYSTEM_PROMPT, buildUserPrompt(mailMessage));\n try {\n MailAiAnalysisResponse parsed = objectMapper.readValue(modelJson, MailAiAnalysisResponse.class);\n return new MailAiAnalysisResponse(\n parsed.emailType(),\n parsed.brief(),\n parsed.keyPoints(),\n parsed.senderIntent(),\n parsed.tone(),\n parsed.actionItems(),\n parsed.risks(),\n parsed.nextStep(),\n parsed.suggestedResponse(),\n properties.getModel(),\n LocalDateTime.now()\n );\n } catch (JsonProcessingException ex) {\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n }\n }\n\n private String buildUserPrompt(MailMessage mailMessage) {\n BodyInput bodyInput = buildBodyInput(mailMessage);\n return \"\"\"\n 请分析下面这封邮件,并输出结构化 JSON。\n\n 输出格式:\n {\n \"emailType\": \"WORK | PERSONAL | NOTIFICATION | BILL | VERIFICATION | MARKETING | SYSTEM | RISK | OTHER | UNKNOWN\",\n \"brief\": \"一句话概括邮件核心内容,最多 100 个中文字符\",\n \"keyPoints\": [\"关键点 1\", \"关键点 2\"],\n \"senderIntent\": {\n \"type\": \"INFORM_ONLY | REQUEST_ACTION | REQUEST_REPLY | DELEGATE_TASK | FOLLOW_UP | FEEDBACK | ENCOURAGEMENT | WARNING | PRESSURE | NEGOTIATION | PROMOTION | VERIFY_IDENTITY | PAYMENT_RELATED | UNKNOWN\",\n \"label\": \"中文标签\",\n \"explanation\": \"简要解释发件人意图\"\n },\n \"tone\": {\n \"type\": \"NEUTRAL | FRIENDLY | ENCOURAGING | FORMAL | URGENT | PRESSURING | CRITICAL | PASSIVE_AGGRESSIVE | MANIPULATIVE | SUSPICIOUS | UNCLEAR\",\n \"label\": \"中文标签\",\n \"confidence\": \"LOW | MEDIUM | HIGH\",\n \"signals\": [\"判断依据 1\", \"判断依据 2\"],\n \"caution\": \"谨慎提示,如果没有特殊风险则说明仅供参考\"\n },\n \"actionItems\": [\n {\n \"task\": \"需要处理的事项\",\n \"deadline\": \"截止时间,没有则写“不明确”\",\n \"priority\": \"LOW | MEDIUM | HIGH | URGENT | UNKNOWN\",\n \"evidence\": \"邮件中的文本依据\"\n }\n ],\n \"risks\": [\"风险或注意点,没有则返回空数组\"],\n \"nextStep\": \"建议下一步操作,最多 120 个中文字符\",\n \"suggestedResponse\": {\n \"strategy\": \"建议回复策略,没有必要回复则说明无需回复\",\n \"draft\": \"一段简短回复草稿,如果无需回复则为空字符串\"\n }\n }\n\n 注意:\n - 不要编造邮件中没有的截止时间、任务或关系。\n - 如果没有待处理事项,actionItems 返回空数组。\n - 如果没有明显风险,risks 返回空数组。\n - 如果语气正常,不要强行指出负面含义。\n - 如果可能存在情绪压力或可疑风险,请给出具体文本依据,并保持谨慎表述。\n %s\n\n 邮件信息:\n 主题:%s\n 发件人:%s\n 收件人:%s\n 发送时间:%s\n 接收时间:%s\n 摘要:%s\n 正文:\n %s\n \"\"\".formatted(\n bodyInput.truncated() ? \"- 正文因长度限制已截断,请只基于可见内容判断。\" : \"\",\n safe(mailMessage.getSubject()),\n safe(mailMessage.getFromAddress()),\n safe(mailMessage.getToAddresses()),\n safe(mailMessage.getSentAt()),\n safe(mailMessage.getReceivedAt()),\n safe(mailMessage.getSnippet()),\n bodyInput.body()\n );\n }\n\n private BodyInput buildBodyInput(MailMessage mailMessage) {\n String body = mailMessage.getTextBody();\n if (body == null || body.isBlank()) {\n body = stripHtml(mailMessage.getHtmlBody());\n }\n body = safe(body);\n int maxChars = Math.max(1000, properties.getMaxInputChars());\n if (body.length() <= maxChars) {\n return new BodyInput(body, false);\n }\n return new BodyInput(body.substring(0, maxChars), true);\n }\n\n private String stripHtml(String html) {\n if (html == null || html.isBlank()) {\n return \"\";\n }\n return html.replaceAll(\"<[^>]*>\", \" \").replaceAll(\"\\\\s+\", \" \").trim();\n }\n\n private String safe(Object value) {\n return value == null ? \"\" : value.toString();\n }\n\n private record BodyInput(String body, boolean truncated) {\n }\n}\n"}, "2915b55690605c337a0a3ba3f24aae9b20db18de": {"t": 1785751010.199343, "n": 7202, "h": "4692d8981ee5b46e", "c": "package com.mailnest.mailai;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.mailnest.common.ResourceNotFoundException;\nimport com.mailnest.mailaccount.MailAccountMapper;\nimport com.mailnest.mailai.dto.MailAiAnalysisResponse;\nimport com.mailnest.mailmessage.MailMessage;\nimport com.mailnest.mailmessage.MailMessageMapper;\nimport com.mailnest.security.CurrentUser;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.stereotype.Service;\n\nimport java.time.LocalDateTime;\n\n@Service\npublic class MailAiService {\n\n private static final String SYSTEM_PROMPT = \"\"\"\n 你是 MailNest 的邮件分析助手,服务对象是需要快速理解和处理邮件的普通用户。\n\n 你的任务不是泛泛总结邮件,而是帮助用户判断:\n - 这封邮件属于什么类型;\n - 发件人的主要意图是什么;\n - 邮件有哪些关键重点;\n - 是否有明确待处理事项、截止时间或优先级;\n - 语气是否中性、友好、催促、紧急、可疑或存在情绪压力;\n - 用户下一步应该如何处理。\n\n 你必须遵守:\n 1. 只基于用户提供的邮件内容分析,不要编造背景、关系或事实。\n 2. 如果邮件内容不足以判断,请明确说明“不确定”。\n 3. 对“情绪操控、施压、可疑风险”等判断必须谨慎,只能作为可能信号,不能下绝对结论。\n 4. 如果指出语气或风险信号,必须给出邮件文本中的依据。\n 5. 输出应帮助用户冷静、专业、高效地处理邮件,不要激化冲突。\n 6. 使用简体中文。\n 7. 只输出合法 JSON,不要输出 Markdown,不要添加额外解释。\n \"\"\";\n\n private final MailAccountMapper mailAccountMapper;\n private final MailMessageMapper mailMessageMapper;\n private final AiChatClient aiChatClient;\n private final MailAiProperties properties;\n private final ObjectMapper objectMapper = new ObjectMapper();\n\n public MailAiService(MailAccountMapper mailAccountMapper,\n MailMessageMapper mailMessageMapper,\n AiChatClient aiChatClient,\n MailAiProperties properties) {\n this.mailAccountMapper = mailAccountMapper;\n this.mailMessageMapper = mailMessageMapper;\n this.aiChatClient = aiChatClient;\n this.properties = properties;\n }\n\n public MailAiAnalysisResponse analyze(CurrentUser currentUser, Long accountId, Long messageId) {\n if (!properties.isEnabled()) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未启用\");\n }\n if (properties.getApiKey() == null || properties.getApiKey().isBlank()) {\n throw new MailAiException(HttpStatus.SERVICE_UNAVAILABLE, \"AI 服务未配置,请先设置 SILICONFLOW_API_KEY\");\n }\n mailAccountMapper.findByIdAndUserId(accountId, currentUser.id())\n .orElseThrow(() -> new ResourceNotFoundException(\"邮箱账号不存在\"));\n MailMessage mailMessage = mailMessageMapper.findByIdAndAccountId(messageId, accountId)\n .orElseThrow(() -> new ResourceNotFoundException(\"邮件不存在\"));\n String modelJson = aiChatClient.completeJson(SYSTEM_PROMPT, buildUserPrompt(mailMessage));\n try {\n MailAiAnalysisResponse parsed = objectMapper.readValue(modelJson, MailAiAnalysisResponse.class);\n return new MailAiAnalysisResponse(\n parsed.emailType(),\n parsed.brief(),\n parsed.keyPoints(),\n parsed.senderIntent(),\n parsed.tone(),\n parsed.actionItems(),\n parsed.risks(),\n parsed.nextStep(),\n parsed.suggestedResponse(),\n properties.getModel(),\n LocalDateTime.now()\n );\n } catch (JsonProcessingException ex) {\n throw new MailAiException(HttpStatus.BAD_GATEWAY, \"AI 返回格式异常\");\n }\n }\n\n private String buildUserPrompt(MailMessage mailMessage) {\n BodyInput bodyInput = buildBodyInput(mailMessage);\n return \"\"\"\n 请分析下面这封邮件,并输出结构化 JSON。\n\n 输出格式:\n {\n \"emailType\": \"WORK | PERSONAL | NOTIFICATION | BILL | VERIFICATION | MARKETING | SYSTEM | RISK | OTHER | UNKNOWN\",\n \"brief\": \"一句话概括邮件核心内容,最多 100 个中文字符\",\n \"keyPoints\": [\"关键点 1\", \"关键点 2\"],\n \"senderIntent\": {\n \"type\": \"INFORM_ONLY | REQUEST_ACTION | REQUEST_REPLY | DELEGATE_TASK | FOLLOW_UP | FEEDBACK | ENCOURAGEMENT | WARNING | PRESSURE | NEGOTIATION | PROMOTION | VERIFY_IDENTITY | PAYMENT_RELATED | UNKNOWN\",\n \"label\": \"中文标签\",\n \"explanation\": \"简要解释发件人意图\"\n },\n \"tone\": {\n \"type\": \"NEUTRAL | FRIENDLY | ENCOURAGING | FORMAL | URGENT | PRESSURING | CRITICAL | PASSIVE_AGGRESSIVE | MANIPULATIVE | SUSPICIOUS | UNCLEAR\",\n \"label\": \"中文标签\",\n \"confidence\": \"LOW | MEDIUM | HIGH\",\n \"signals\": [\"判断依据 1\", \"判断依据 2\"],\n \"caution\": \"谨慎提示,如果没有特殊风险则说明仅供参考\"\n },\n \"actionItems\": [\n {\n \"task\": \"需要处理的事项\",\n \"deadline\": \"截止时间,没有则写“不明确”\",\n \"priority\": \"LOW | MEDIUM | HIGH | URGENT | UNKNOWN\",\n \"evidence\": \"邮件中的文本依据\"\n }\n ],\n \"risks\": [\"风险或注意点,没有则返回空数组\"],\n \"nextStep\": \"建议下一步操作,最多 120 个中文字符\",\n \"suggestedResponse\": {\n \"strategy\": \"建议回复策略,没有必要回复则说明无需回复\",\n \"draft\": \"一段简短回复草稿,如果无需回复则为空字符串\"\n }\n }\n\n 注意:\n - 不要编造邮件中没有的截止时间、任务或关系。\n - 如果没有待处理事项,actionItems 返回空数组。\n - 如果没有明显风险,risks 返回空数组。\n - 如果语气正常,不要强行指出负面含义。\n - 如果可能存在情绪压力或可疑风险,请给出具体文本依据,并保持谨慎表述。\n %s\n\n 邮件信息:\n 主题:%s\n 发件人:%s\n 收件人:%s\n 发送时间:%s\n 接收时间:%s\n 摘要:%s\n 正文:\n %s\n \"\"\".formatted(\n bodyInput.truncated() ? \"- 正文因长度限制已截断,请只基于可见内容判断。\" : \"\",\n safe(mailMessage.getSubject()),\n safe(mailMessage.getFromAddress()),\n safe(mailMessage.getToAddresses()),\n safe(mailMessage.getSentAt()),\n safe(mailMessage.getReceivedAt()),\n safe(mailMessage.getSnippet()),\n bodyInput.body()\n );\n }\n\n private BodyInput buildBodyInput(MailMessage mailMessage) {\n String body = mailMessage.getTextBody();\n if (body == null || body.isBlank()) {\n body = stripHtml(mailMessage.getHtmlBody());\n }\n body = safe(body);\n int maxChars = Math.max(1000, properties.getMaxInputChars());\n if (body.length() <= maxChars) {\n return new BodyInput(body, false);\n }\n return new BodyInput(body.substring(0, maxChars), true);\n }\n\n private String stripHtml(String html) {\n if (html == null || html.isBlank()) {\n return \"\";\n }\n return html.replaceAll(\"<[^>]*>\", \" \").replaceAll(\"\\\\s+\", \" \").trim();\n }\n\n private String safe(Object value) {\n return value == null ? \"\" : value.toString();\n }\n\n private record BodyInput(String body, boolean truncated) {\n }\n}\n"}}}}