1947 lines
78 KiB
Go
1947 lines
78 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/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 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{"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 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)...)
|
|
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 {
|
|
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.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.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})
|
|
}
|
|
}
|