ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2004 lines
80 KiB
Go
2004 lines
80 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"data-terminal-cloud/internal/concurrency"
|
|
"data-terminal-cloud/internal/devicebinding"
|
|
"data-terminal-cloud/internal/deviceconfig"
|
|
"data-terminal-cloud/internal/identity"
|
|
"data-terminal-cloud/internal/pagination"
|
|
"data-terminal-cloud/internal/saas"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func ListHouseholdsHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := webPrincipal(writer, request)
|
|
if !ok {
|
|
return
|
|
}
|
|
tenantID := strings.TrimSpace(request.Header.Get("X-Tenant-Id"))
|
|
if tenantID != "" && !validUUID(tenantID) {
|
|
writeValidation(writer, request, "客户组织编号不正确", FieldError{Field: "X-Tenant-Id", Message: "必须是UUID"})
|
|
return
|
|
}
|
|
items, err := service.ListHouseholds(request.Context(), principal, tenantID)
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, map[string]any{"items": items})
|
|
})
|
|
}
|
|
|
|
func CreateHouseholdHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := webPrincipal(writer, request)
|
|
if !ok {
|
|
return
|
|
}
|
|
tenantID := strings.TrimSpace(request.Header.Get("X-Tenant-Id"))
|
|
if !validUUID(tenantID) {
|
|
writeValidation(writer, request, "创建家庭参数不正确", FieldError{Field: "X-Tenant-Id", Message: "必须提供有效的客户组织UUID"})
|
|
return
|
|
}
|
|
var input struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if !decodeWebJSON(writer, request, &input, "创建家庭参数不正确") {
|
|
return
|
|
}
|
|
input.Name = strings.TrimSpace(input.Name)
|
|
if input.Name == "" || len([]rune(input.Name)) > 200 {
|
|
writeValidation(writer, request, "创建家庭参数不正确", FieldError{Field: "name", Message: "不能为空且最长200字符"})
|
|
return
|
|
}
|
|
result, err := service.CreateHousehold(request.Context(), principal, tenantID, input.Name, RequestID(request.Context()))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
|
WriteJSON(writer, request, http.StatusCreated, result)
|
|
})
|
|
}
|
|
|
|
func GetHouseholdHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := service.GetHousehold(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func PatchHouseholdHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
expected, err := concurrency.ParseIfMatch(request)
|
|
if err != nil {
|
|
writeETagError(writer, request, err)
|
|
return
|
|
}
|
|
var input struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if !decodeWebJSON(writer, request, &input, "修改家庭参数不正确") {
|
|
return
|
|
}
|
|
input.Name = strings.TrimSpace(input.Name)
|
|
if input.Name == "" || len([]rune(input.Name)) > 200 {
|
|
writeValidation(writer, request, "修改家庭参数不正确", FieldError{Field: "name", Message: "不能为空且最长200字符"})
|
|
return
|
|
}
|
|
result, err := service.PatchHousehold(request.Context(), principal, request.PathValue("id"), input.Name, expected, RequestID(request.Context()))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func ListHouseholdMembersHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := service.ListHouseholdMembers(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, map[string]any{"items": items})
|
|
})
|
|
}
|
|
|
|
func ListMemberInvitationsHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := service.ListMemberInvitations(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, map[string]any{"items": items})
|
|
})
|
|
}
|
|
|
|
func CreateMemberInvitationHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var input struct {
|
|
InviteeHint string `json:"invitee_hint"`
|
|
Role identity.Role `json:"role"`
|
|
ExpiresInHours int `json:"expires_in_hours"`
|
|
}
|
|
if !decodeWebJSON(writer, request, &input, "成员邀请参数不正确") {
|
|
return
|
|
}
|
|
input.InviteeHint = strings.TrimSpace(input.InviteeHint)
|
|
if input.ExpiresInHours == 0 {
|
|
input.ExpiresInHours = 168
|
|
}
|
|
fieldErrors := make([]FieldError, 0)
|
|
if len([]rune(input.InviteeHint)) > 255 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "invitee_hint", Message: "最长255字符"})
|
|
}
|
|
if !oneOf(string(input.Role), "admin", "editor", "viewer") {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "role", Message: "只允许admin、editor或viewer"})
|
|
}
|
|
if input.ExpiresInHours < 1 || input.ExpiresInHours > 720 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "expires_in_hours", Message: "必须为1至720小时"})
|
|
}
|
|
if len(fieldErrors) > 0 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{Code: "VALIDATION_FAILED", Message: "成员邀请参数不正确", FieldErrors: fieldErrors})
|
|
return
|
|
}
|
|
result, err := service.CreateMemberInvitation(request.Context(), principal, request.PathValue("id"), input.InviteeHint, input.Role, time.Now().UTC().Add(time.Duration(input.ExpiresInHours)*time.Hour), RequestID(request.Context()))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
WriteJSON(writer, request, http.StatusCreated, result)
|
|
})
|
|
}
|
|
|
|
func RevokeMemberInvitationHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if !validUUID(request.PathValue("invitation_id")) {
|
|
writeValidation(writer, request, "邀请编号不正确", FieldError{Field: "invitation_id", Message: "必须是UUID"})
|
|
return
|
|
}
|
|
result, err := service.RevokeMemberInvitation(request.Context(), principal, request.PathValue("id"), request.PathValue("invitation_id"), RequestID(request.Context()))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func PreviewMemberInvitationHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
if _, ok := webPrincipal(writer, request); !ok {
|
|
return
|
|
}
|
|
var input struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if !decodeWebJSON(writer, request, &input, "邀请预览参数不正确") {
|
|
return
|
|
}
|
|
input.Token = strings.TrimSpace(input.Token)
|
|
if len(input.Token) < 32 || len(input.Token) > 128 {
|
|
writeValidation(writer, request, "邀请预览参数不正确", FieldError{Field: "token", Message: "邀请凭据长度不正确"})
|
|
return
|
|
}
|
|
result, err := service.PreviewMemberInvitation(request.Context(), input.Token)
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func AcceptMemberInvitationHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := webPrincipal(writer, request)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if !decodeWebJSON(writer, request, &input, "接受邀请参数不正确") {
|
|
return
|
|
}
|
|
input.Token = strings.TrimSpace(input.Token)
|
|
if len(input.Token) < 32 || len(input.Token) > 128 {
|
|
writeValidation(writer, request, "接受邀请参数不正确", FieldError{Field: "token", Message: "邀请凭据长度不正确"})
|
|
return
|
|
}
|
|
result, replayed, err := service.AcceptMemberInvitation(request.Context(), principal, input.Token, RequestID(request.Context()))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
if replayed {
|
|
writer.Header().Set("Idempotency-Replayed", "true")
|
|
}
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
WriteJSON(writer, request, http.StatusCreated, result)
|
|
})
|
|
}
|
|
|
|
func PatchHouseholdMemberHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if !validUUID(request.PathValue("member_id")) {
|
|
writeValidation(writer, request, "成员编号不正确", FieldError{Field: "member_id", Message: "必须是UUID"})
|
|
return
|
|
}
|
|
expected, err := concurrency.ParseIfMatch(request)
|
|
if err != nil {
|
|
writeETagError(writer, request, err)
|
|
return
|
|
}
|
|
var input struct {
|
|
Role identity.Role `json:"role"`
|
|
}
|
|
if !decodeWebJSON(writer, request, &input, "成员角色参数不正确") {
|
|
return
|
|
}
|
|
if !oneOf(string(input.Role), "admin", "editor", "viewer") {
|
|
writeValidation(writer, request, "成员角色参数不正确", FieldError{Field: "role", Message: "只允许admin、editor或viewer;所有权必须使用转移接口"})
|
|
return
|
|
}
|
|
result, err := service.PatchHouseholdMemberRole(request.Context(), principal, request.PathValue("id"), request.PathValue("member_id"), input.Role, expected, RequestID(request.Context()))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func RemoveHouseholdMemberHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if !validUUID(request.PathValue("member_id")) {
|
|
writeValidation(writer, request, "成员编号不正确", FieldError{Field: "member_id", Message: "必须是UUID"})
|
|
return
|
|
}
|
|
expected, err := concurrency.ParseIfMatch(request)
|
|
if err != nil {
|
|
writeETagError(writer, request, err)
|
|
return
|
|
}
|
|
result, err := service.RemoveHouseholdMember(request.Context(), principal, request.PathValue("id"), request.PathValue("member_id"), expected, RequestID(request.Context()))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func TransferHouseholdOwnershipHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
expected, err := concurrency.ParseIfMatch(request)
|
|
if err != nil {
|
|
writeETagError(writer, request, err)
|
|
return
|
|
}
|
|
var input struct {
|
|
MemberID string `json:"member_id"`
|
|
}
|
|
if !decodeWebJSON(writer, request, &input, "转移所有权参数不正确") {
|
|
return
|
|
}
|
|
if !validUUID(input.MemberID) {
|
|
writeValidation(writer, request, "转移所有权参数不正确", FieldError{Field: "member_id", Message: "必须是有效成员UUID"})
|
|
return
|
|
}
|
|
result, err := service.TransferHouseholdOwnership(request.Context(), principal, request.PathValue("id"), input.MemberID, expected, RequestID(request.Context()))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func ListSubjectsHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := service.ListSubjects(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, map[string]any{"items": items})
|
|
})
|
|
}
|
|
|
|
func CreateSubjectHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var input saas.CreateSubjectInput
|
|
if !decodeWebJSON(writer, request, &input, "创建长者参数不正确") {
|
|
return
|
|
}
|
|
input.PreferredName = strings.TrimSpace(input.PreferredName)
|
|
input.Language = strings.TrimSpace(input.Language)
|
|
input.Timezone = strings.TrimSpace(input.Timezone)
|
|
if input.ProfileData == nil {
|
|
input.ProfileData = map[string]any{}
|
|
}
|
|
if input.ConversationPreferences == nil {
|
|
input.ConversationPreferences = map[string]any{}
|
|
}
|
|
if fieldErrors := validateSubject(input.PreferredName, input.Language, input.Timezone, input.ProfileData, input.ConversationPreferences); len(fieldErrors) > 0 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{Code: "VALIDATION_FAILED", Message: "创建长者参数不正确", FieldErrors: fieldErrors})
|
|
return
|
|
}
|
|
result, err := service.CreateSubject(request.Context(), principal, request.PathValue("id"), RequestID(request.Context()), input)
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
|
WriteJSON(writer, request, http.StatusCreated, result)
|
|
})
|
|
}
|
|
|
|
func GetSubjectHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := service.GetSubject(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func EnsureSubjectVoiceAgentHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := resourcePrincipal(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
info, err := service.EnsureSubjectVoiceAgent(r.Context(), principal, r.PathValue("id"))
|
|
if err != nil {
|
|
if info.Status == "FAILED" {
|
|
WriteAPIError(w, r, http.StatusBadGateway, Error{Code: "VOICE_AGENT_CREATE_FAILED", Message: info.Message})
|
|
return
|
|
}
|
|
writeSaaSError(w, r, err)
|
|
return
|
|
}
|
|
WriteJSON(w, r, http.StatusOK, info)
|
|
})
|
|
}
|
|
|
|
func GetConversationReadinessHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := service.GetConversationReadiness(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func PatchSubjectHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
expected, err := concurrency.ParseIfMatch(request)
|
|
if err != nil {
|
|
writeETagError(writer, request, err)
|
|
return
|
|
}
|
|
var input saas.PatchSubjectInput
|
|
if !decodeWebJSON(writer, request, &input, "修改长者参数不正确") {
|
|
return
|
|
}
|
|
if input.PreferredName == nil && input.Language == nil && input.Timezone == nil && input.ProfileData == nil && input.ConversationPreferences == nil && input.Status == nil {
|
|
writeValidation(writer, request, "修改长者参数不正确", FieldError{Field: "body", Message: "至少提供一个待修改字段"})
|
|
return
|
|
}
|
|
fieldErrors := make([]FieldError, 0)
|
|
if input.PreferredName != nil {
|
|
value := strings.TrimSpace(*input.PreferredName)
|
|
input.PreferredName = &value
|
|
if value == "" || len([]rune(value)) > 120 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "preferred_name", Message: "不能为空且最长120字符"})
|
|
}
|
|
}
|
|
if input.Language != nil {
|
|
value := strings.TrimSpace(*input.Language)
|
|
input.Language = &value
|
|
if !validLanguage(value) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "language", Message: "必须是有效的语言标签,最长32字符"})
|
|
}
|
|
}
|
|
if input.Timezone != nil {
|
|
value := strings.TrimSpace(*input.Timezone)
|
|
input.Timezone = &value
|
|
if !validTimezone(value) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "timezone", Message: "必须是有效的IANA时区"})
|
|
}
|
|
}
|
|
if input.ProfileData != nil {
|
|
fieldErrors = append(fieldErrors, validateSubjectContext(*input.ProfileData, nil)...)
|
|
}
|
|
if input.ConversationPreferences != nil {
|
|
fieldErrors = append(fieldErrors, validateSubjectContext(nil, *input.ConversationPreferences)...)
|
|
}
|
|
if input.Status != nil && *input.Status != "active" && *input.Status != "suspended" {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "status", Message: "只允许active或suspended"})
|
|
}
|
|
if len(fieldErrors) > 0 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{Code: "VALIDATION_FAILED", Message: "修改长者参数不正确", FieldErrors: fieldErrors})
|
|
return
|
|
}
|
|
result, err := service.PatchSubject(request.Context(), principal, request.PathValue("id"), RequestID(request.Context()), expected, input)
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func ListConsentsHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := service.ListConsents(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, map[string]any{"items": items})
|
|
})
|
|
}
|
|
|
|
func ListSubjectFactsHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := service.ListSubjectFacts(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, map[string]any{"items": items})
|
|
})
|
|
}
|
|
|
|
func CreateSubjectFactHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var input saas.CreateFactInput
|
|
if !decodeWebJSON(writer, request, &input, "创建稳定事实参数不正确") {
|
|
return
|
|
}
|
|
normalizeCreateFact(&input)
|
|
if fieldErrors := validateFact(input.FactType, input.Content, input.SourceType, input.SourceRef, input.ConfirmationStatus, input.Sensitivity, input.ValidFrom, input.ValidTo); len(fieldErrors) > 0 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{Code: "VALIDATION_FAILED", Message: "创建稳定事实参数不正确", FieldErrors: fieldErrors})
|
|
return
|
|
}
|
|
result, err := service.CreateSubjectFact(request.Context(), principal, request.PathValue("id"), RequestID(request.Context()), input)
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
|
WriteJSON(writer, request, http.StatusCreated, result)
|
|
})
|
|
}
|
|
|
|
func PatchSubjectFactHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if !validUUID(request.PathValue("fact_id")) {
|
|
writeValidation(writer, request, "事实编号不正确", FieldError{Field: "fact_id", Message: "必须是UUID"})
|
|
return
|
|
}
|
|
expected, err := concurrency.ParseIfMatch(request)
|
|
if err != nil {
|
|
writeETagError(writer, request, err)
|
|
return
|
|
}
|
|
var input saas.PatchFactInput
|
|
if !decodeWebJSON(writer, request, &input, "修改稳定事实参数不正确") {
|
|
return
|
|
}
|
|
if input.FactType == nil && input.Content == nil && input.SourceRef == nil && input.ConfirmationStatus == nil && input.Sensitivity == nil && input.ValidFrom == nil && input.ValidTo == nil {
|
|
writeValidation(writer, request, "修改稳定事实参数不正确", FieldError{Field: "body", Message: "至少提供一个待修改字段"})
|
|
return
|
|
}
|
|
fieldErrors := make([]FieldError, 0)
|
|
if input.FactType != nil {
|
|
value := strings.TrimSpace(*input.FactType)
|
|
input.FactType = &value
|
|
if value == "" || len(value) > 64 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "fact_type", Message: "不能为空且最长64字符"})
|
|
}
|
|
}
|
|
if input.Content != nil {
|
|
value := strings.TrimSpace(*input.Content)
|
|
input.Content = &value
|
|
if value == "" || len([]rune(value)) > 4000 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "content", Message: "不能为空且最长4000字符"})
|
|
}
|
|
}
|
|
if input.SourceRef != nil {
|
|
value := strings.TrimSpace(*input.SourceRef)
|
|
input.SourceRef = &value
|
|
if len(value) > 300 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "source_ref", Message: "最长300字符"})
|
|
}
|
|
}
|
|
if input.ConfirmationStatus != nil && !oneOf(*input.ConfirmationStatus, "pending", "confirmed", "rejected", "superseded") {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "confirmation_status", Message: "状态不正确"})
|
|
}
|
|
if input.Sensitivity != nil && !oneOf(*input.Sensitivity, "normal", "sensitive", "high") {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "sensitivity", Message: "敏感等级不正确"})
|
|
}
|
|
if input.ValidFrom != nil && input.ValidTo != nil && input.ValidTo.Before(*input.ValidFrom) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "valid_to", Message: "不能早于valid_from"})
|
|
}
|
|
if len(fieldErrors) > 0 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{Code: "VALIDATION_FAILED", Message: "修改稳定事实参数不正确", FieldErrors: fieldErrors})
|
|
return
|
|
}
|
|
result, err := service.PatchSubjectFact(request.Context(), principal, request.PathValue("id"), request.PathValue("fact_id"), RequestID(request.Context()), expected, input)
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func GrantConsentHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var input saas.GrantConsentInput
|
|
if !decodeWebJSON(writer, request, &input, "授权参数不正确") {
|
|
return
|
|
}
|
|
input.Type = strings.TrimSpace(input.Type)
|
|
input.PolicyVersion = strings.TrimSpace(input.PolicyVersion)
|
|
input.NoticeDigest = strings.ToLower(strings.TrimSpace(input.NoticeDigest))
|
|
input.EvidenceRef = strings.TrimSpace(input.EvidenceRef)
|
|
input.GrantedAt = time.Now().UTC()
|
|
if input.Scope == nil {
|
|
input.Scope = map[string]any{}
|
|
}
|
|
fieldErrors := make([]FieldError, 0)
|
|
if !saas.IsConsentType(input.Type) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "type", Message: "不是支持的分项授权类型"})
|
|
}
|
|
if input.PolicyVersion == "" || len(input.PolicyVersion) > 64 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "policy_version", Message: "不能为空且最长64字符"})
|
|
}
|
|
if len(input.NoticeDigest) != 64 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "notice_digest", Message: "必须是64位SHA-256十六进制摘要"})
|
|
} else if _, err := hex.DecodeString(input.NoticeDigest); err != nil {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "notice_digest", Message: "必须是SHA-256十六进制摘要"})
|
|
}
|
|
if len(input.EvidenceRef) > 300 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "evidence_ref", Message: "最长300字符"})
|
|
}
|
|
if input.ExpiresAt != nil && !input.ExpiresAt.After(input.GrantedAt) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "expires_at", Message: "必须晚于授权时间"})
|
|
}
|
|
if len(fieldErrors) > 0 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{Code: "VALIDATION_FAILED", Message: "授权参数不正确", FieldErrors: fieldErrors})
|
|
return
|
|
}
|
|
result, err := service.GrantConsent(request.Context(), principal, request.PathValue("id"), RequestID(request.Context()), input)
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusCreated, result)
|
|
})
|
|
}
|
|
|
|
func WithdrawConsentHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if !validUUID(request.PathValue("consent_id")) {
|
|
writeValidation(writer, request, "授权编号不正确", FieldError{Field: "consent_id", Message: "必须是UUID"})
|
|
return
|
|
}
|
|
var input struct{}
|
|
if request.Body != nil && request.ContentLength != 0 && !decodeWebJSON(writer, request, &input, "撤回授权参数不正确") {
|
|
return
|
|
}
|
|
result, err := service.WithdrawConsent(request.Context(), principal, request.PathValue("id"), request.PathValue("consent_id"), RequestID(request.Context()))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func ListManagedDevicesHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := webPrincipal(writer, request)
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := service.ListDevices(request.Context(), principal)
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, map[string]any{"items": items})
|
|
})
|
|
}
|
|
|
|
func ClaimDeviceHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := webPrincipal(writer, request)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input struct {
|
|
ClaimCode string `json:"claim_code"`
|
|
HouseholdID string `json:"household_id"`
|
|
SubjectID string `json:"subject_id"`
|
|
}
|
|
if !decodeWebJSON(writer, request, &input, "设备绑定参数不正确") {
|
|
return
|
|
}
|
|
input.ClaimCode = strings.TrimSpace(input.ClaimCode)
|
|
fieldErrors := make([]FieldError, 0)
|
|
if len(input.ClaimCode) < 16 || len(input.ClaimCode) > 128 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "claim_code", Message: "长度必须为16至128字符"})
|
|
}
|
|
if validUUID(input.ClaimCode) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "claim_code", Message: "不能填写设备编号,请输入设备屏幕或维护端生成的一次性绑定码"})
|
|
}
|
|
if !validUUID(input.HouseholdID) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "household_id", Message: "必须是UUID"})
|
|
}
|
|
if !validUUID(input.SubjectID) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "subject_id", Message: "必须是UUID"})
|
|
}
|
|
if len(fieldErrors) > 0 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{Code: "VALIDATION_FAILED", Message: "设备绑定参数不正确", FieldErrors: fieldErrors})
|
|
return
|
|
}
|
|
result, replayed, err := service.ClaimDevice(request.Context(), principal, input.ClaimCode, input.HouseholdID, input.SubjectID, RequestID(request.Context()))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
if replayed {
|
|
writer.Header().Set("Idempotency-Replayed", "true")
|
|
}
|
|
WriteJSON(writer, request, http.StatusCreated, result)
|
|
})
|
|
}
|
|
|
|
func GetManagedDeviceHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := service.GetDevice(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func GetDeviceBindingImpactHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
action := strings.TrimSpace(request.URL.Query().Get("action"))
|
|
targetSubjectID := strings.TrimSpace(request.URL.Query().Get("target_subject_id"))
|
|
fieldErrors := make([]FieldError, 0)
|
|
if action != saas.DeviceBindingActionUnbind && action != saas.DeviceBindingActionTransfer {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "action", Message: "必须是unbind或transfer"})
|
|
}
|
|
if action == saas.DeviceBindingActionTransfer && !validUUID(targetSubjectID) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "target_subject_id", Message: "转移时必须提供目标长者UUID"})
|
|
}
|
|
if len(fieldErrors) > 0 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{Code: "VALIDATION_FAILED", Message: "设备绑定影响检查参数不正确", FieldErrors: fieldErrors})
|
|
return
|
|
}
|
|
result, err := service.GetDeviceBindingImpact(request.Context(), principal, request.PathValue("id"), action, targetSubjectID)
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func ChangeDeviceBindingHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var input struct {
|
|
Action string `json:"action"`
|
|
ExpectedBindingID string `json:"expected_binding_id"`
|
|
TargetSubjectID string `json:"target_subject_id"`
|
|
Reason string `json:"reason"`
|
|
Acknowledged bool `json:"acknowledged"`
|
|
}
|
|
if !decodeWebJSON(writer, request, &input, "设备绑定变更参数不正确") {
|
|
return
|
|
}
|
|
input.Action = strings.TrimSpace(input.Action)
|
|
input.ExpectedBindingID = strings.TrimSpace(input.ExpectedBindingID)
|
|
input.TargetSubjectID = strings.TrimSpace(input.TargetSubjectID)
|
|
input.Reason = strings.TrimSpace(input.Reason)
|
|
fieldErrors := make([]FieldError, 0)
|
|
if input.Action != saas.DeviceBindingActionUnbind && input.Action != saas.DeviceBindingActionTransfer {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "action", Message: "必须是unbind或transfer"})
|
|
}
|
|
if !validUUID(input.ExpectedBindingID) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "expected_binding_id", Message: "必须是影响检查返回的当前绑定UUID"})
|
|
}
|
|
if input.Action == saas.DeviceBindingActionTransfer && !validUUID(input.TargetSubjectID) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "target_subject_id", Message: "转移时必须提供目标长者UUID"})
|
|
}
|
|
if len(input.Reason) < 3 || len(input.Reason) > 300 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "reason", Message: "长度必须为3至300字符"})
|
|
}
|
|
if !input.Acknowledged {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "acknowledged", Message: "必须确认已核对影响范围"})
|
|
}
|
|
if len(fieldErrors) > 0 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{Code: "VALIDATION_FAILED", Message: "设备绑定变更参数不正确", FieldErrors: fieldErrors})
|
|
return
|
|
}
|
|
result, err := service.ChangeDeviceBinding(request.Context(), principal, request.PathValue("id"), RequestID(request.Context()), saas.ChangeDeviceBindingInput{
|
|
Action: input.Action, ExpectedBindingID: input.ExpectedBindingID, TargetSubjectID: input.TargetSubjectID, Reason: input.Reason, Acknowledged: input.Acknowledged,
|
|
})
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func GetDevicePresenceHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := service.GetDevicePresence(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func GetDeviceTwinHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := service.GetDeviceTwin(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.DesiredRevision))
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func PatchDeviceTwinHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
expected, err := concurrency.ParseIfMatch(request)
|
|
if err != nil {
|
|
writeETagError(writer, request, err)
|
|
return
|
|
}
|
|
var input struct {
|
|
DesiredConfig map[string]any `json:"desired_config"`
|
|
}
|
|
if !decodeWebJSON(writer, request, &input, "设备配置参数不正确") {
|
|
return
|
|
}
|
|
if input.DesiredConfig == nil {
|
|
writeValidation(writer, request, "设备配置参数不正确", FieldError{Field: "desired_config", Message: "必须是JSON对象"})
|
|
return
|
|
}
|
|
if field, ok := forbiddenDeviceConfigField(input.DesiredConfig, "desired_config"); ok {
|
|
writeValidation(writer, request, "设备配置包含禁止字段", FieldError{Field: field, Message: "不得下发密钥、令牌、口令或凭据"})
|
|
return
|
|
}
|
|
allowed := map[string]bool{"power": true, "identity": true, "volume": true, "interaction": true, "upload": true, "rtc": true, "policy_refs": true}
|
|
for key := range input.DesiredConfig {
|
|
if !allowed[key] {
|
|
writeValidation(writer, request, "设备配置参数不正确", FieldError{Field: "desired_config." + key, Message: "不是允许下发的非敏感配置分类"})
|
|
return
|
|
}
|
|
}
|
|
if fieldErrors := validateDesiredDeviceConfig(input.DesiredConfig); len(fieldErrors) > 0 {
|
|
writeValidation(writer, request, "设备配置参数不正确", fieldErrors...)
|
|
return
|
|
}
|
|
result, err := service.PatchDeviceTwin(request.Context(), principal, request.PathValue("id"), RequestID(request.Context()), expected, input.DesiredConfig)
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.DesiredRevision))
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func ListSubjectConversationsHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
query := request.URL.Query()
|
|
limit, err := pagination.ParseLimit(query.Get("limit"))
|
|
if err != nil {
|
|
writeValidation(writer, request, "分页参数不正确", FieldError{Field: "limit", Message: "必须是1至100的整数"})
|
|
return
|
|
}
|
|
filter := saas.ConversationFilter{Status: strings.TrimSpace(query.Get("status")), EngineMode: strings.TrimSpace(query.Get("engine_mode")), Limit: limit, Cursor: strings.TrimSpace(query.Get("cursor"))}
|
|
fieldErrors := make([]FieldError, 0)
|
|
if filter.Status != "" && !oneOf(filter.Status, "PREPARING", "ROOM_ISSUED", "STARTING_AGENT", "ACTIVE", "STOPPING", "COMPLETED", "FAILED", "EXPIRED") {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "status", Message: "通话状态不正确"})
|
|
}
|
|
if filter.EngineMode != "" && !oneOf(filter.EngineMode, "s2s_pure", "s2s_hybrid", "cascade") {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "engine_mode", Message: "语音处理方式不正确"})
|
|
}
|
|
if value := strings.TrimSpace(query.Get("from")); value != "" {
|
|
parsed, parseErr := time.Parse(time.RFC3339, value)
|
|
if parseErr != nil {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "from", Message: "必须是RFC3339时间"})
|
|
} else {
|
|
filter.From = &parsed
|
|
}
|
|
}
|
|
if value := strings.TrimSpace(query.Get("to")); value != "" {
|
|
parsed, parseErr := time.Parse(time.RFC3339, value)
|
|
if parseErr != nil {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "to", Message: "必须是RFC3339时间"})
|
|
} else {
|
|
filter.To = &parsed
|
|
}
|
|
}
|
|
if filter.From != nil && filter.To != nil && !filter.From.Before(*filter.To) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "to", Message: "必须晚于from"})
|
|
}
|
|
if len(fieldErrors) > 0 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{Code: "VALIDATION_FAILED", Message: "通话查询参数不正确", FieldErrors: fieldErrors})
|
|
return
|
|
}
|
|
result, err := service.ListSubjectConversations(request.Context(), principal, request.PathValue("id"), filter)
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func GetConversationHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := service.GetConversation(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func GetConversationUsageHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := service.GetConversationUsage(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func ListConversationTurnsHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
limit, err := pagination.ParseLimit(request.URL.Query().Get("limit"))
|
|
if err != nil {
|
|
writeValidation(writer, request, "分页参数不正确", FieldError{Field: "limit", Message: "必须是1至100的整数"})
|
|
return
|
|
}
|
|
result, err := service.ListConversationTurns(request.Context(), principal, request.PathValue("id"), limit, strings.TrimSpace(request.URL.Query().Get("cursor")))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func ListConversationSummariesHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := service.ListConversationContentSummaries(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, map[string]any{"items": items})
|
|
})
|
|
}
|
|
|
|
func RegenerateConversationSummaryHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
version, err := service.RegenerateConversationSummary(request.Context(), principal, request.PathValue("id"), RequestID(request.Context()))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusAccepted, map[string]any{"generation_version": version, "status": "queued"})
|
|
})
|
|
}
|
|
|
|
func ListSubjectStoriesHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
status := strings.TrimSpace(request.URL.Query().Get("status"))
|
|
if status != "" && !oneOf(status, "CANDIDATE", "IN_REVIEW", "CONFIRMED", "REJECTED", "SUPERSEDED") {
|
|
writeValidation(writer, request, "故事查询参数不正确", FieldError{Field: "status", Message: "故事状态不正确"})
|
|
return
|
|
}
|
|
items, err := service.ListSubjectStories(request.Context(), principal, request.PathValue("id"), status)
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, map[string]any{"items": items})
|
|
})
|
|
}
|
|
|
|
func GetStoryHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := service.GetStory(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func PatchStoryHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
expected, err := concurrency.ParseIfMatch(request)
|
|
if err != nil {
|
|
writeETagError(writer, request, err)
|
|
return
|
|
}
|
|
var input struct {
|
|
Content string `json:"content"`
|
|
Reason string `json:"reason"`
|
|
ErrorType string `json:"error_type"`
|
|
}
|
|
if !decodeWebJSON(writer, request, &input, "故事修订参数不正确") {
|
|
return
|
|
}
|
|
input.Content = strings.TrimSpace(input.Content)
|
|
input.Reason = strings.TrimSpace(input.Reason)
|
|
input.ErrorType = strings.TrimSpace(input.ErrorType)
|
|
if input.Content == "" || len([]rune(input.Content)) > 50000 || input.Reason == "" || len([]rune(input.Reason)) > 500 {
|
|
writeValidation(writer, request, "故事修订参数不正确",
|
|
FieldError{Field: "content", Message: "不能为空且最长50000字符"},
|
|
FieldError{Field: "reason", Message: "不能为空且最长500字符"})
|
|
return
|
|
}
|
|
if input.ErrorType != "" && !saas.IsContentErrorType(input.ErrorType) {
|
|
writeValidation(writer, request, "故事修订参数不正确", FieldError{Field: "error_type", Message: "质量问题类型不正确"})
|
|
return
|
|
}
|
|
result, err := service.ReviseStory(request.Context(), principal, request.PathValue("id"), input.Content, input.Reason, RequestID(request.Context()), expected, input.ErrorType)
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func UpdateFactCandidateHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if !validUUID(request.PathValue("fact_id")) {
|
|
writeValidation(writer, request, "事实候选编号不正确", FieldError{Field: "fact_id", Message: "必须是UUID"})
|
|
return
|
|
}
|
|
expected, err := concurrency.ParseIfMatch(request)
|
|
if err != nil {
|
|
writeETagError(writer, request, err)
|
|
return
|
|
}
|
|
var input struct {
|
|
FactType *string `json:"fact_type"`
|
|
Content *string `json:"content"`
|
|
Sensitivity *string `json:"sensitivity"`
|
|
Status *string `json:"status"`
|
|
ErrorType *string `json:"error_type"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if !decodeWebJSON(writer, request, &input, "事实候选审核参数不正确") {
|
|
return
|
|
}
|
|
input.Reason = strings.TrimSpace(input.Reason)
|
|
fieldErrors := make([]FieldError, 0)
|
|
if input.Reason == "" || len([]rune(input.Reason)) > 500 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "reason", Message: "不能为空且最长500字符"})
|
|
}
|
|
if input.FactType != nil {
|
|
value := strings.TrimSpace(*input.FactType)
|
|
input.FactType = &value
|
|
if value == "" || len(value) > 64 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "fact_type", Message: "不能为空且最长64字符"})
|
|
}
|
|
}
|
|
if input.Content != nil {
|
|
value := strings.TrimSpace(*input.Content)
|
|
input.Content = &value
|
|
if value == "" || len([]rune(value)) > 4000 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "content", Message: "不能为空且最长4000字符"})
|
|
}
|
|
}
|
|
if input.Sensitivity != nil && *input.Sensitivity != "normal" && *input.Sensitivity != "sensitive" && *input.Sensitivity != "high" {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "sensitivity", Message: "必须为normal、sensitive或high"})
|
|
}
|
|
if input.Status != nil && *input.Status != "CANDIDATE" && *input.Status != "CONFIRMED" && *input.Status != "REJECTED" && *input.Status != "SUPERSEDED" {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "status", Message: "事实候选状态不正确"})
|
|
}
|
|
if input.ErrorType != nil {
|
|
value := strings.TrimSpace(*input.ErrorType)
|
|
input.ErrorType = &value
|
|
if value == "" || !saas.IsContentErrorType(value) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "error_type", Message: "质量问题类型不正确"})
|
|
}
|
|
}
|
|
if input.FactType == nil && input.Content == nil && input.Sensitivity == nil && input.Status == nil {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "body", Message: "至少提供一个需要修改或审核的字段"})
|
|
}
|
|
if len(fieldErrors) > 0 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{Code: "VALIDATION_FAILED", Message: "事实候选审核参数不正确", FieldErrors: fieldErrors})
|
|
return
|
|
}
|
|
result, err := service.UpdateFactCandidate(request.Context(), principal, request.PathValue("id"), request.PathValue("fact_id"), RequestID(request.Context()), expected, saas.UpdateFactCandidateInput{
|
|
FactType: input.FactType, Content: input.Content, Sensitivity: input.Sensitivity, Status: input.Status, ErrorType: input.ErrorType, Reason: input.Reason,
|
|
})
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func ConfirmStoryHandler(service *saas.Service) http.Handler {
|
|
return storyDecisionHandler(service, true)
|
|
}
|
|
|
|
func RejectStoryHandler(service *saas.Service) http.Handler {
|
|
return storyDecisionHandler(service, false)
|
|
}
|
|
|
|
func storyDecisionHandler(service *saas.Service, confirm bool) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
expected, err := concurrency.ParseIfMatch(request)
|
|
if err != nil {
|
|
writeETagError(writer, request, err)
|
|
return
|
|
}
|
|
var input struct {
|
|
Reason string `json:"reason"`
|
|
ErrorType string `json:"error_type"`
|
|
}
|
|
if !decodeWebJSON(writer, request, &input, "故事审核参数不正确") {
|
|
return
|
|
}
|
|
input.Reason = strings.TrimSpace(input.Reason)
|
|
input.ErrorType = strings.TrimSpace(input.ErrorType)
|
|
if input.Reason == "" || len([]rune(input.Reason)) > 500 {
|
|
writeValidation(writer, request, "故事审核参数不正确", FieldError{Field: "reason", Message: "不能为空且最长500字符"})
|
|
return
|
|
}
|
|
if input.ErrorType != "" && !saas.IsContentErrorType(input.ErrorType) {
|
|
writeValidation(writer, request, "故事审核参数不正确", FieldError{Field: "error_type", Message: "质量问题类型不正确"})
|
|
return
|
|
}
|
|
if confirm {
|
|
story, memory, err := service.ConfirmStory(request.Context(), principal, request.PathValue("id"), input.Reason, RequestID(request.Context()), expected)
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(story.Version))
|
|
WriteJSON(writer, request, http.StatusOK, map[string]any{"story": story, "memory": memory})
|
|
return
|
|
}
|
|
story, err := service.RejectStory(request.Context(), principal, request.PathValue("id"), input.Reason, RequestID(request.Context()), expected, input.ErrorType)
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(story.Version))
|
|
WriteJSON(writer, request, http.StatusOK, story)
|
|
})
|
|
}
|
|
|
|
func ListSubjectMemoriesHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
query := strings.TrimSpace(request.URL.Query().Get("q"))
|
|
if len([]rune(query)) > 200 {
|
|
writeValidation(writer, request, "记忆查询参数不正确", FieldError{Field: "q", Message: "最长200字符"})
|
|
return
|
|
}
|
|
items, err := service.ListSubjectMemories(request.Context(), principal, request.PathValue("id"), query)
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, map[string]any{"items": items})
|
|
})
|
|
}
|
|
|
|
func GetMemoryHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := service.GetMemory(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func PatchMemoryHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
expected, err := concurrency.ParseIfMatch(request)
|
|
if err != nil {
|
|
writeETagError(writer, request, err)
|
|
return
|
|
}
|
|
var input struct {
|
|
Content *string `json:"content"`
|
|
Status *string `json:"status"`
|
|
ErrorType *string `json:"error_type"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if !decodeWebJSON(writer, request, &input, "记忆修改参数不正确") {
|
|
return
|
|
}
|
|
input.Reason = strings.TrimSpace(input.Reason)
|
|
fieldErrors := make([]FieldError, 0)
|
|
if input.Content == nil && input.Status == nil {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "body", Message: "必须提供content或status"})
|
|
}
|
|
if input.Content != nil {
|
|
trimmed := strings.TrimSpace(*input.Content)
|
|
input.Content = &trimmed
|
|
if trimmed == "" || len([]rune(trimmed)) > 50000 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "content", Message: "不能为空且最长50000字符"})
|
|
}
|
|
}
|
|
if input.Status != nil && !oneOf(*input.Status, "ACTIVE", "DISABLED") {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "status", Message: "只允许ACTIVE或DISABLED"})
|
|
}
|
|
if input.ErrorType != nil {
|
|
value := strings.TrimSpace(*input.ErrorType)
|
|
input.ErrorType = &value
|
|
if value == "" || !saas.IsContentErrorType(value) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "error_type", Message: "质量问题类型不正确"})
|
|
}
|
|
}
|
|
if input.Reason == "" || len([]rune(input.Reason)) > 500 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "reason", Message: "不能为空且最长500字符"})
|
|
}
|
|
if len(fieldErrors) > 0 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{Code: "VALIDATION_FAILED", Message: "记忆修改参数不正确", FieldErrors: fieldErrors})
|
|
return
|
|
}
|
|
result, err := service.UpdateMemory(request.Context(), principal, request.PathValue("id"), RequestID(request.Context()), expected, saas.UpdateMemoryInput{Content: input.Content, Status: input.Status, ErrorType: input.ErrorType, Reason: input.Reason})
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func DeleteMemoryHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
expected, err := concurrency.ParseIfMatch(request)
|
|
if err != nil {
|
|
writeETagError(writer, request, err)
|
|
return
|
|
}
|
|
var input struct {
|
|
Reason string `json:"reason"`
|
|
}
|
|
if !decodeWebJSON(writer, request, &input, "记忆删除参数不正确") {
|
|
return
|
|
}
|
|
input.Reason = strings.TrimSpace(input.Reason)
|
|
if input.Reason == "" || len([]rune(input.Reason)) > 500 {
|
|
writeValidation(writer, request, "记忆删除参数不正确", FieldError{Field: "reason", Message: "不能为空且最长500字符"})
|
|
return
|
|
}
|
|
result, err := service.RequestMemoryDeletion(request.Context(), principal, request.PathValue("id"), input.Reason, RequestID(request.Context()), expected)
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
|
WriteJSON(writer, request, http.StatusAccepted, result)
|
|
})
|
|
}
|
|
|
|
func ListHouseholdPrivacyRequestsHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := service.ListHouseholdPrivacyRequests(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, map[string]any{"items": items})
|
|
})
|
|
}
|
|
|
|
func ListPrivacyRequestsHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := service.ListPrivacyRequests(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, map[string]any{"items": items})
|
|
})
|
|
}
|
|
|
|
func CreatePrivacyRequestHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(request.Header.Get("Idempotency-Key"))
|
|
if len(idempotencyKey) < 16 || len(idempotencyKey) > 128 {
|
|
writeValidation(writer, request, "缺少有效的重复请求识别码", FieldError{Field: "Idempotency-Key", Message: "长度必须为16至128字符"})
|
|
return
|
|
}
|
|
var input struct {
|
|
RequestType string `json:"request_type"`
|
|
Reason string `json:"reason"`
|
|
Confirmation string `json:"confirmation"`
|
|
Scope struct {
|
|
Categories []string `json:"categories"`
|
|
ConsentTypes []string `json:"consent_types"`
|
|
Corrections struct {
|
|
PreferredName *string `json:"preferred_name"`
|
|
Language *string `json:"language"`
|
|
Timezone *string `json:"timezone"`
|
|
ProfileData *map[string]any `json:"profile_data"`
|
|
ConversationPreferences *map[string]any `json:"conversation_preferences"`
|
|
} `json:"corrections"`
|
|
} `json:"scope"`
|
|
}
|
|
if !decodeWebJSON(writer, request, &input, "隐私请求参数不正确") {
|
|
return
|
|
}
|
|
input.RequestType = strings.ToUpper(strings.TrimSpace(input.RequestType))
|
|
if input.RequestType != "ACCESS" && input.RequestType != "CORRECTION" && input.RequestType != "CONSENT_WITHDRAWAL" && input.RequestType != "DELETE" {
|
|
writeValidation(writer, request, "隐私请求参数不正确", FieldError{Field: "request_type", Message: "必须为ACCESS、CORRECTION、CONSENT_WITHDRAWAL或DELETE"})
|
|
return
|
|
}
|
|
if input.RequestType == "DELETE" {
|
|
input.Reason = strings.TrimSpace(input.Reason)
|
|
input.Confirmation = strings.TrimSpace(input.Confirmation)
|
|
if input.Reason == "" || len([]rune(input.Reason)) > 500 {
|
|
writeValidation(writer, request, "删除申请参数不正确", FieldError{Field: "reason", Message: "不能为空且最长500字符"})
|
|
return
|
|
}
|
|
if input.Confirmation == "" {
|
|
writeValidation(writer, request, "删除申请参数不正确", FieldError{Field: "confirmation", Message: "必须输入主体称呼进行确认"})
|
|
return
|
|
}
|
|
}
|
|
allowed := map[string]bool{"profile": true, "consents": true, "device_bindings": true, "conversations": true, "transcripts": true, "summaries": true, "stories": true, "memories": true}
|
|
seen := map[string]bool{}
|
|
categories := make([]string, 0, len(input.Scope.Categories))
|
|
for index, raw := range input.Scope.Categories {
|
|
category := strings.TrimSpace(raw)
|
|
if !allowed[category] {
|
|
writeValidation(writer, request, "隐私请求参数不正确", FieldError{Field: fmt.Sprintf("scope.categories[%d]", index), Message: "数据分类不正确"})
|
|
return
|
|
}
|
|
if !seen[category] {
|
|
seen[category] = true
|
|
categories = append(categories, category)
|
|
}
|
|
}
|
|
scope := map[string]any{}
|
|
if len(categories) > 0 {
|
|
scope["categories"] = categories
|
|
}
|
|
if input.RequestType == "CORRECTION" {
|
|
corrections := map[string]any{}
|
|
if input.Scope.Corrections.PreferredName != nil {
|
|
value := strings.TrimSpace(*input.Scope.Corrections.PreferredName)
|
|
if len([]rune(value)) < 1 || len([]rune(value)) > 120 {
|
|
writeValidation(writer, request, "更正申请参数不正确", FieldError{Field: "scope.corrections.preferred_name", Message: "长度必须为1至120字符"})
|
|
return
|
|
}
|
|
corrections["preferred_name"] = value
|
|
}
|
|
if input.Scope.Corrections.Language != nil {
|
|
value := strings.TrimSpace(*input.Scope.Corrections.Language)
|
|
if len(value) < 2 || len(value) > 20 {
|
|
writeValidation(writer, request, "更正申请参数不正确", FieldError{Field: "scope.corrections.language", Message: "长度必须为2至20字符"})
|
|
return
|
|
}
|
|
corrections["language"] = value
|
|
}
|
|
if input.Scope.Corrections.Timezone != nil {
|
|
value := strings.TrimSpace(*input.Scope.Corrections.Timezone)
|
|
if _, err := time.LoadLocation(value); err != nil {
|
|
writeValidation(writer, request, "更正申请参数不正确", FieldError{Field: "scope.corrections.timezone", Message: "必须是有效IANA时区"})
|
|
return
|
|
}
|
|
corrections["timezone"] = value
|
|
}
|
|
if input.Scope.Corrections.ProfileData != nil {
|
|
corrections["profile_data"] = *input.Scope.Corrections.ProfileData
|
|
}
|
|
if input.Scope.Corrections.ConversationPreferences != nil {
|
|
corrections["conversation_preferences"] = *input.Scope.Corrections.ConversationPreferences
|
|
}
|
|
if len(corrections) == 0 {
|
|
writeValidation(writer, request, "更正申请参数不正确", FieldError{Field: "scope.corrections", Message: "至少提供一个需要更正的字段"})
|
|
return
|
|
}
|
|
scope = map[string]any{"corrections": corrections}
|
|
}
|
|
if input.RequestType == "CONSENT_WITHDRAWAL" {
|
|
allowedConsentTypes := map[string]bool{
|
|
saas.ConsentTranscriptStorage: true, saas.ConsentRawAudioStorage: true, saas.ConsentStoryExtraction: true,
|
|
saas.ConsentPersonalMemory: true, saas.ConsentFamilySharing: true,
|
|
}
|
|
seenConsentTypes := map[string]bool{}
|
|
consentTypes := make([]string, 0, len(input.Scope.ConsentTypes))
|
|
for index, raw := range input.Scope.ConsentTypes {
|
|
consentType := strings.TrimSpace(raw)
|
|
if !allowedConsentTypes[consentType] {
|
|
writeValidation(writer, request, "授权撤回申请参数不正确", FieldError{Field: fmt.Sprintf("scope.consent_types[%d]", index), Message: "授权类型不正确"})
|
|
return
|
|
}
|
|
if !seenConsentTypes[consentType] {
|
|
seenConsentTypes[consentType] = true
|
|
consentTypes = append(consentTypes, consentType)
|
|
}
|
|
}
|
|
if len(consentTypes) == 0 {
|
|
writeValidation(writer, request, "授权撤回申请参数不正确", FieldError{Field: "scope.consent_types", Message: "至少选择一种授权"})
|
|
return
|
|
}
|
|
scope = map[string]any{"consent_types": consentTypes}
|
|
}
|
|
result, created, err := service.CreatePrivacyRequest(request.Context(), principal, request.PathValue("id"), idempotencyKey, RequestID(request.Context()), saas.CreatePrivacyRequestInput{RequestType: input.RequestType, Scope: scope, Reason: input.Reason, Confirmation: input.Confirmation})
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
status := http.StatusAccepted
|
|
if !created {
|
|
writer.Header().Set("Idempotency-Replayed", "true")
|
|
status = http.StatusOK
|
|
}
|
|
WriteJSON(writer, request, status, result)
|
|
})
|
|
}
|
|
|
|
func GetPrivacyRequestHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := service.GetPrivacyRequest(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func ListExportTasksHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := service.ListExportTasks(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
WriteJSON(writer, request, http.StatusOK, map[string]any{"items": items})
|
|
})
|
|
}
|
|
|
|
func CreateExportTaskHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(request.Header.Get("Idempotency-Key"))
|
|
if len(idempotencyKey) < 16 || len(idempotencyKey) > 128 {
|
|
writeValidation(writer, request, "缺少有效的重复请求识别码", FieldError{Field: "Idempotency-Key", Message: "长度必须为16至128字符"})
|
|
return
|
|
}
|
|
var input struct {
|
|
Scope struct {
|
|
Categories []string `json:"categories"`
|
|
} `json:"scope"`
|
|
}
|
|
if !decodeWebJSON(writer, request, &input, "数据导出参数不正确") {
|
|
return
|
|
}
|
|
categories, err := saas.NormalizeExportCategories(input.Scope.Categories)
|
|
if err != nil {
|
|
writeValidation(writer, request, "数据导出参数不正确", FieldError{Field: "scope.categories", Message: "包含不支持的数据分类"})
|
|
return
|
|
}
|
|
scope := map[string]any{}
|
|
if len(categories) > 0 {
|
|
scope["categories"] = categories
|
|
}
|
|
result, created, err := service.CreateExportTask(request.Context(), principal, request.PathValue("id"), idempotencyKey, RequestID(request.Context()), scope)
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
status := http.StatusAccepted
|
|
if !created {
|
|
writer.Header().Set("Idempotency-Replayed", "true")
|
|
status = http.StatusOK
|
|
}
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
WriteJSON(writer, request, status, result)
|
|
})
|
|
}
|
|
|
|
func GetExportTaskHandler(service *saas.Service) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
principal, ok := resourcePrincipal(writer, request, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := service.GetExportTask(request.Context(), principal, request.PathValue("id"))
|
|
if err != nil {
|
|
writeSaaSError(writer, request, err)
|
|
return
|
|
}
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
|
})
|
|
}
|
|
|
|
func webPrincipal(writer http.ResponseWriter, request *http.Request) (identity.Principal, bool) {
|
|
principal, ok := identity.PrincipalFromContext(request.Context())
|
|
if !ok {
|
|
WriteError(writer, request, http.StatusUnauthorized, "AUTHENTICATION_REQUIRED", "请先登录后再访问")
|
|
}
|
|
return principal, ok
|
|
}
|
|
|
|
func resourcePrincipal(writer http.ResponseWriter, request *http.Request, pathName string) (identity.Principal, bool) {
|
|
principal, ok := webPrincipal(writer, request)
|
|
if !ok {
|
|
return identity.Principal{}, false
|
|
}
|
|
if !validUUID(request.PathValue(pathName)) {
|
|
writeValidation(writer, request, "资源编号不正确", FieldError{Field: pathName, Message: "必须是UUID"})
|
|
return identity.Principal{}, false
|
|
}
|
|
return principal, true
|
|
}
|
|
|
|
func decodeWebJSON(writer http.ResponseWriter, request *http.Request, target any, message string) bool {
|
|
decoder := json.NewDecoder(request.Body)
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(target); err != nil || ensureJSONEOF(decoder) != nil {
|
|
writeValidation(writer, request, message, FieldError{Field: "body", Message: "必须是符合契约的单个JSON对象"})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validateSubject(name, language, timezone string, profile, preferences map[string]any) []FieldError {
|
|
result := make([]FieldError, 0)
|
|
if name == "" || len([]rune(name)) > 120 {
|
|
result = append(result, FieldError{Field: "preferred_name", Message: "不能为空且最长120字符"})
|
|
}
|
|
if !validLanguage(language) {
|
|
result = append(result, FieldError{Field: "language", Message: "必须是有效的语言标签,最长32字符"})
|
|
}
|
|
if !validTimezone(timezone) {
|
|
result = append(result, FieldError{Field: "timezone", Message: "必须是有效的IANA时区"})
|
|
}
|
|
if profile == nil {
|
|
result = append(result, FieldError{Field: "profile_data", Message: "必须是JSON对象"})
|
|
}
|
|
if preferences == nil {
|
|
result = append(result, FieldError{Field: "conversation_preferences", Message: "必须是JSON对象"})
|
|
}
|
|
result = append(result, validateSubjectContext(profile, preferences)...)
|
|
return result
|
|
}
|
|
|
|
func validateSubjectContext(profile, preferences map[string]any) []FieldError {
|
|
result := make([]FieldError, 0)
|
|
result = append(result, validateOptionalContextString(profile, "background", "profile_data.background", 500)...)
|
|
result = append(result, validateOptionalContextList(profile, "interests", "profile_data.interests", 8, 50)...)
|
|
result = append(result, validateOptionalContextString(preferences, "conversation_goal", "conversation_preferences.conversation_goal", 200)...)
|
|
result = append(result, validateOptionalContextList(preferences, "preferred_topics", "conversation_preferences.preferred_topics", 8, 50)...)
|
|
result = append(result, validateOptionalContextList(preferences, "avoided_topics", "conversation_preferences.avoided_topics", 8, 50)...)
|
|
if preferences != nil {
|
|
if raw, exists := preferences["pace"]; exists {
|
|
pace, ok := raw.(string)
|
|
if !ok || (pace != "slow" && pace != "normal" && pace != "lively") {
|
|
result = append(result, FieldError{Field: "conversation_preferences.pace", Message: "只允许slow、normal或lively"})
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func validateOptionalContextString(values map[string]any, key, field string, limit int) []FieldError {
|
|
if values == nil {
|
|
return nil
|
|
}
|
|
raw, exists := values[key]
|
|
if !exists {
|
|
return nil
|
|
}
|
|
value, ok := raw.(string)
|
|
if !ok || len([]rune(strings.TrimSpace(value))) > limit {
|
|
return []FieldError{{Field: field, Message: fmt.Sprintf("必须是最长%d字符的文本", limit)}}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateOptionalContextList(values map[string]any, key, field string, maxItems, itemLimit int) []FieldError {
|
|
if values == nil {
|
|
return nil
|
|
}
|
|
raw, exists := values[key]
|
|
if !exists {
|
|
return nil
|
|
}
|
|
items, ok := raw.([]any)
|
|
if !ok || len(items) > maxItems {
|
|
return []FieldError{{Field: field, Message: fmt.Sprintf("必须是最多%d项的文本数组", maxItems)}}
|
|
}
|
|
for _, item := range items {
|
|
value, ok := item.(string)
|
|
if !ok || strings.TrimSpace(value) == "" || len([]rune(strings.TrimSpace(value))) > itemLimit {
|
|
return []FieldError{{Field: field, Message: fmt.Sprintf("每项必须是1至%d字符的文本", itemLimit)}}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validLanguage(value string) bool {
|
|
if value == "" || len(value) > 32 {
|
|
return false
|
|
}
|
|
for _, character := range value {
|
|
if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || character == '-' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validTimezone(value string) bool {
|
|
if value == "" || len(value) > 64 {
|
|
return false
|
|
}
|
|
_, err := time.LoadLocation(value)
|
|
return err == nil
|
|
}
|
|
|
|
func validUUID(value string) bool { _, err := uuid.Parse(value); return err == nil }
|
|
|
|
func normalizeCreateFact(input *saas.CreateFactInput) {
|
|
input.FactType = strings.TrimSpace(input.FactType)
|
|
input.Content = strings.TrimSpace(input.Content)
|
|
input.SourceType = strings.TrimSpace(input.SourceType)
|
|
input.SourceRef = strings.TrimSpace(input.SourceRef)
|
|
input.ConfirmationStatus = strings.TrimSpace(input.ConfirmationStatus)
|
|
input.Sensitivity = strings.TrimSpace(input.Sensitivity)
|
|
}
|
|
|
|
func validateFact(factType, content, sourceType, sourceRef, status, sensitivity string, validFrom, validTo *time.Time) []FieldError {
|
|
result := make([]FieldError, 0)
|
|
if factType == "" || len(factType) > 64 {
|
|
result = append(result, FieldError{Field: "fact_type", Message: "不能为空且最长64字符"})
|
|
}
|
|
if content == "" || len([]rune(content)) > 4000 {
|
|
result = append(result, FieldError{Field: "content", Message: "不能为空且最长4000字符"})
|
|
}
|
|
if !oneOf(sourceType, "subject_statement", "family_entry", "model_candidate", "imported") {
|
|
result = append(result, FieldError{Field: "source_type", Message: "来源类型不正确"})
|
|
}
|
|
if len(sourceRef) > 300 {
|
|
result = append(result, FieldError{Field: "source_ref", Message: "最长300字符"})
|
|
}
|
|
if !oneOf(status, "pending", "confirmed", "rejected", "superseded") {
|
|
result = append(result, FieldError{Field: "confirmation_status", Message: "状态不正确"})
|
|
}
|
|
if status == "confirmed" && sourceRef == "" {
|
|
result = append(result, FieldError{Field: "source_ref", Message: "确认事实必须保留来源引用"})
|
|
}
|
|
if !oneOf(sensitivity, "normal", "sensitive", "high") {
|
|
result = append(result, FieldError{Field: "sensitivity", Message: "敏感等级不正确"})
|
|
}
|
|
if validFrom != nil && validTo != nil && validTo.Before(*validFrom) {
|
|
result = append(result, FieldError{Field: "valid_to", Message: "不能早于valid_from"})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func oneOf(value string, allowed ...string) bool {
|
|
for _, candidate := range allowed {
|
|
if value == candidate {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func forbiddenDeviceConfigField(value any, path string) (string, bool) {
|
|
forbidden := []string{"secret", "token", "password", "credential", "private_key", "api_key"}
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
for key, child := range typed {
|
|
lower := strings.ToLower(key)
|
|
for _, fragment := range forbidden {
|
|
if strings.Contains(lower, fragment) {
|
|
return path + "." + key, true
|
|
}
|
|
}
|
|
if found, ok := forbiddenDeviceConfigField(child, path+"."+key); ok {
|
|
return found, true
|
|
}
|
|
}
|
|
case []any:
|
|
for index, child := range typed {
|
|
if found, ok := forbiddenDeviceConfigField(child, path+"["+strconv.Itoa(index)+"]"); ok {
|
|
return found, true
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func validateDesiredDeviceConfig(config map[string]any) []FieldError {
|
|
if value, exists := config["power"]; exists {
|
|
object, ok := value.(map[string]any)
|
|
mode, isString := object["mode"].(string)
|
|
if !ok || len(object) != 1 || !isString || (mode != "active" && mode != "standby") {
|
|
return []FieldError{{Field: "desired_config.power.mode", Message: "必须是active或standby"}}
|
|
}
|
|
}
|
|
if value, exists := config["identity"]; exists {
|
|
object, ok := value.(map[string]any)
|
|
name, isString := object["display_name"].(string)
|
|
if !ok || len(object) != 1 || !isString || !deviceconfig.ValidDisplayName(name) {
|
|
return []FieldError{{Field: "desired_config.identity.display_name", Message: "名称最多96字节,不含首尾空白或控制字符;留空恢复默认名称"}}
|
|
}
|
|
}
|
|
volume, exists := config["volume"]
|
|
if !exists {
|
|
return nil
|
|
}
|
|
volumeObject, ok := volume.(map[string]any)
|
|
if !ok {
|
|
return []FieldError{{Field: "desired_config.volume", Message: "必须是JSON对象"}}
|
|
}
|
|
speaker, exists := volumeObject["speaker"]
|
|
if !exists {
|
|
return nil
|
|
}
|
|
speakerNumber, ok := speaker.(float64)
|
|
if !ok || speakerNumber < 0 || speakerNumber > 100 || speakerNumber != float64(int(speakerNumber)) {
|
|
return []FieldError{{Field: "desired_config.volume.speaker", Message: "必须是0至100的整数"}}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeValidation(writer http.ResponseWriter, request *http.Request, message string, fieldErrors ...FieldError) {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{Code: "VALIDATION_FAILED", Message: message, FieldErrors: fieldErrors})
|
|
}
|
|
|
|
func writeETagError(writer http.ResponseWriter, request *http.Request, err error) {
|
|
if errors.Is(err, concurrency.ErrPreconditionRequired) {
|
|
WriteError(writer, request, http.StatusPreconditionRequired, "PRECONDITION_REQUIRED", "修改资源必须提供If-Match")
|
|
return
|
|
}
|
|
if errors.Is(err, concurrency.ErrVersionConflict) {
|
|
WriteError(writer, request, http.StatusPreconditionFailed, "VERSION_CONFLICT", "资源已被其他操作修改,请刷新后重试")
|
|
return
|
|
}
|
|
writeValidation(writer, request, "If-Match格式不正确", FieldError{Field: "If-Match", Message: "必须使用服务端返回的强ETag"})
|
|
}
|
|
|
|
func writeSaaSError(writer http.ResponseWriter, request *http.Request, err error) {
|
|
switch {
|
|
case errors.Is(err, saas.ErrDevicePowerUnsupported):
|
|
WriteError(writer, request, http.StatusConflict, "DEVICE_POWER_UNSUPPORTED", "设备尚未确认支持远程待机,请先更新固件")
|
|
case errors.Is(err, devicebinding.ErrConfirmationRequired):
|
|
WriteError(writer, request, http.StatusConflict, "DEVICE_CONFIRMATION_REQUIRED", "请先授权解绑,再从设备设置入口申请并按键确认绑定")
|
|
case errors.Is(err, saas.ErrAccessDenied):
|
|
WriteError(writer, request, http.StatusForbidden, "RESOURCE_ACCESS_DENIED", "当前账号无权访问该资源")
|
|
case errors.Is(err, saas.ErrNotFound):
|
|
WriteError(writer, request, http.StatusNotFound, "RESOURCE_NOT_FOUND", "请求的资源不存在")
|
|
case errors.Is(err, saas.ErrVersionConflict):
|
|
WriteError(writer, request, http.StatusPreconditionFailed, "VERSION_CONFLICT", "资源已被其他操作修改,请刷新后重试")
|
|
case errors.Is(err, saas.ErrEffectiveConsentExist):
|
|
WriteError(writer, request, http.StatusConflict, "EFFECTIVE_CONSENT_EXISTS", "该类型已有当前有效的授权")
|
|
case errors.Is(err, saas.ErrPolicyVersionMismatch):
|
|
WriteError(writer, request, http.StatusConflict, "POLICY_VERSION_MISMATCH", "授权政策版本已经更新,请重新阅读并确认")
|
|
case errors.Is(err, saas.ErrInitialConsentInvalid):
|
|
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "请逐项选择授权并确认用途")
|
|
case errors.Is(err, saas.ErrDeviceClaimInvalid):
|
|
WriteError(writer, request, http.StatusConflict, "DEVICE_CLAIM_INVALID", "设备绑定码无效、已过期或已撤销")
|
|
case errors.Is(err, saas.ErrDeviceClaimUsed):
|
|
WriteError(writer, request, http.StatusConflict, "DEVICE_CLAIM_ALREADY_USED", "设备绑定码已经用于其他绑定")
|
|
case errors.Is(err, saas.ErrDeviceAlreadyBound):
|
|
WriteError(writer, request, http.StatusConflict, "DEVICE_ALREADY_BOUND", "设备已经存在有效绑定")
|
|
case errors.Is(err, saas.ErrDeviceBindingBlocked):
|
|
WriteError(writer, request, http.StatusConflict, "DEVICE_BINDING_BLOCKED", "设备仍有活动通话、未完成命令或固件任务,请先处理阻断项")
|
|
case errors.Is(err, saas.ErrDeviceBindingStale):
|
|
WriteError(writer, request, http.StatusConflict, "DEVICE_BINDING_STALE", "设备绑定已发生变化,请重新执行影响检查")
|
|
case errors.Is(err, saas.ErrIdempotencyConflict):
|
|
WriteError(writer, request, http.StatusConflict, "IDEMPOTENCY_KEY_CONFLICT", "重复请求识别码已用于不同的请求内容")
|
|
case errors.Is(err, saas.ErrSubjectDeleteConflict):
|
|
WriteError(writer, request, http.StatusConflict, "SUBJECT_ACTIVE_CONVERSATION", "主体仍有未结束的通话,请先正常结束通话")
|
|
case errors.Is(err, saas.ErrDeleteConfirmation):
|
|
WriteError(writer, request, http.StatusConflict, "DELETE_CONFIRMATION_MISMATCH", "删除确认与主体称呼不一致")
|
|
case errors.Is(err, saas.ErrExportUnavailable):
|
|
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "EXPORT_DOWNLOAD_UNAVAILABLE", Message: "导出文件下载地址暂时不可用", Retryable: true})
|
|
case errors.Is(err, saas.ErrFirmwareStateConflict):
|
|
WriteError(writer, request, http.StatusConflict, "FIRMWARE_STATE_CONFLICT", "固件版本或灰度批次状态不允许当前操作")
|
|
case errors.Is(err, saas.ErrFirmwareSigningKeyConflict):
|
|
WriteError(writer, request, http.StatusConflict, "FIRMWARE_SIGNING_KEY_CONFLICT", "固件签名密钥编号已绑定其他公钥、已被吊销或不存在")
|
|
case errors.Is(err, saas.ErrFirmwareSigningKeyInUse):
|
|
WriteError(writer, request, http.StatusConflict, "FIRMWARE_SIGNING_KEY_IN_USE", "固件签名密钥仍被上传中版本或运行中灰度批次引用")
|
|
case errors.Is(err, saas.ErrFirmwareSigningKeyInactive):
|
|
WriteError(writer, request, http.StatusConflict, "FIRMWARE_SIGNING_KEY_INACTIVE", "固件签名密钥不存在或已被吊销")
|
|
case errors.Is(err, saas.ErrFirmwareReleaseInUse):
|
|
WriteError(writer, request, http.StatusConflict, "FIRMWARE_RELEASE_IN_USE", "固件版本仍有运行中灰度批次,请先暂停版本后再退役")
|
|
case errors.Is(err, saas.ErrInvitationInvalid):
|
|
WriteError(writer, request, http.StatusConflict, "MEMBER_INVITATION_INVALID", "成员邀请无效、已过期或已撤销")
|
|
case errors.Is(err, saas.ErrMemberExists):
|
|
WriteError(writer, request, http.StatusConflict, "HOUSEHOLD_MEMBER_EXISTS", "账号已是家庭成员或已经是目标所有者")
|
|
case errors.Is(err, saas.ErrOwnerProtected):
|
|
WriteError(writer, request, http.StatusConflict, "HOUSEHOLD_OWNER_PROTECTED", "不能直接修改或移除家庭所有者,请先转移所有权")
|
|
case errors.Is(err, saas.ErrFactSourceRequired):
|
|
WriteError(writer, request, http.StatusConflict, "FACT_SOURCE_REQUIRED", "确认事实必须保留可追溯来源")
|
|
case errors.Is(err, saas.ErrStorySourceRequired):
|
|
WriteError(writer, request, http.StatusConflict, "STORY_SOURCE_REQUIRED", "确认故事必须保留可追溯的原始话轮")
|
|
case errors.Is(err, saas.ErrPersonalizedMemoryConsentNeeded):
|
|
WriteError(writer, request, http.StatusConflict, "PERSONALIZED_MEMORY_CONSENT_REQUIRED", "生成个性化记忆前必须取得当前有效授权")
|
|
case errors.Is(err, saas.ErrReviewStateConflict):
|
|
WriteError(writer, request, http.StatusConflict, "STORY_REVIEW_STATE_CONFLICT", "当前故事审核状态不允许此操作")
|
|
case errors.Is(err, saas.ErrFactReviewStateConflict):
|
|
WriteError(writer, request, http.StatusConflict, "FACT_REVIEW_STATE_CONFLICT", "当前事实候选状态不允许此操作")
|
|
case errors.Is(err, saas.ErrConversationSummaryNotReady):
|
|
WriteError(writer, request, http.StatusConflict, "CONVERSATION_SUMMARY_NOT_READY", "聊天尚未结束或没有可用于总结的文字内容")
|
|
case errors.Is(err, pagination.ErrInvalidCursor):
|
|
writeValidation(writer, request, "分页游标无效", FieldError{Field: "cursor", Message: "游标无效、已损坏或不是本服务签发"})
|
|
default:
|
|
WriteAPIError(writer, request, http.StatusInternalServerError, Error{Code: "INTERNAL_ERROR", Message: "服务暂时不可用", Retryable: true})
|
|
}
|
|
}
|