feat: establish v0.70 platform milestone
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-08-29 21:56:51 +08:00
parent d97bbf7cf5
commit e652516147
133 changed files with 12907 additions and 801 deletions
+66
View File
@@ -169,6 +169,72 @@ func GetOpsCostSummaryHandler(service *billing.Service) http.Handler {
})
}
func GetFamilyUsageOverviewHandler(service *billing.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
from, to, timezone, ok := usageOverviewQuery(w, r)
if !ok {
return
}
item, err := service.FamilyUsageOverview(
r.Context(), principal, r.PathValue("id"), r.URL.Query().Get("device_id"), timezone, from, to,
)
if err != nil {
writeBillingError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func GetOpsUsageOverviewHandler(service *billing.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
from, to, timezone, ok := usageOverviewQuery(w, r)
if !ok {
return
}
item, err := service.OpsUsageOverview(
r.Context(), principal, tenantID, r.URL.Query().Get("household_id"), r.URL.Query().Get("device_id"), timezone, from, to,
)
if err != nil {
writeBillingError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func usageOverviewQuery(w http.ResponseWriter, r *http.Request) (time.Time, time.Time, string, bool) {
from, fromErr := time.Parse(time.RFC3339, r.URL.Query().Get("from"))
to, toErr := time.Parse(time.RFC3339, r.URL.Query().Get("to"))
timezone := r.URL.Query().Get("timezone")
if timezone == "" {
timezone = "Asia/Shanghai"
}
if fromErr != nil || toErr != nil {
writeValidation(w, r, "通话用量时间范围不正确", FieldError{Field: "from/to", Message: "必须是RFC3339时间且范围不超过366天"})
return time.Time{}, time.Time{}, "", false
}
if _, err := time.LoadLocation(timezone); err != nil {
writeValidation(w, r, "通话用量时区不正确", FieldError{Field: "timezone", Message: "必须是有效的IANA时区"})
return time.Time{}, time.Time{}, "", false
}
return from, to, timezone, true
}
func writeBillingError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, billing.ErrInvalid):
@@ -0,0 +1,29 @@
package httpapi
import (
"net/http"
"strings"
"data-terminal-cloud/internal/opsconfig"
)
func GetOpsContentQualityHandler(service *opsconfig.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID := strings.TrimSpace(r.Header.Get("X-Tenant-Id"))
if !validUUID(tenantID) {
writeValidation(w, r, "内容质量查询参数不正确", FieldError{Field: "X-Tenant-Id", Message: "必须是有效UUID"})
return
}
items, err := service.ContentQuality(r.Context(), principal, tenantID, r.URL.Query().Get("model_ref"), r.URL.Query().Get("prompt_version"))
if err != nil {
writeOpsConfigError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
+64
View File
@@ -0,0 +1,64 @@
package httpapi
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"data-terminal-cloud/internal/memorytool"
)
func MemoryToolSearchHandler(service *memorytool.Service, signer *memorytool.TokenSigner, limiter RateLimiter, logger *slog.Logger) http.Handler {
if logger == nil {
logger = slog.Default()
}
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
authorization := strings.TrimSpace(request.Header.Get("Authorization"))
if !strings.HasPrefix(authorization, "Bearer ") {
WriteError(writer, request, http.StatusUnauthorized, "MEMORY_TOOL_AUTHENTICATION_FAILED", "记忆工具身份验证失败")
return
}
claims, err := signer.Verify(strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer ")))
if err != nil {
WriteError(writer, request, http.StatusUnauthorized, "MEMORY_TOOL_AUTHENTICATION_FAILED", "记忆工具身份验证失败")
return
}
if limiter != nil && !allowScopes(writer, request, logger, limiter, [][2]string{{"memory_tool", claims.ConversationID}}) {
return
}
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()
var input struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
if err := decoder.Decode(&input); err != nil || ensureJSONEOF(decoder) != nil {
WriteError(writer, request, http.StatusBadRequest, "MEMORY_TOOL_INVALID_REQUEST", "记忆检索参数不正确")
return
}
input.Query = strings.TrimSpace(input.Query)
if input.Limit == 0 {
input.Limit = 3
}
if input.Query == "" || len([]rune(input.Query)) > 500 || input.Limit < 1 || input.Limit > 5 {
WriteError(writer, request, http.StatusBadRequest, "MEMORY_TOOL_INVALID_REQUEST", "记忆检索参数不正确")
return
}
result, err := service.Search(request.Context(), claims, input.Query, input.Limit, RequestID(request.Context()))
if err != nil {
switch {
case errors.Is(err, memorytool.ErrConversationUnavailable):
WriteError(writer, request, http.StatusConflict, "MEMORY_TOOL_CONVERSATION_UNAVAILABLE", "本次通话当前不能使用记忆检索")
case errors.Is(err, memorytool.ErrEngineModeUnsupported):
WriteError(writer, request, http.StatusConflict, "MEMORY_TOOL_ENGINE_MODE_UNSUPPORTED", "本次语音处理方式未启用通话内记忆工具")
default:
slog.ErrorContext(request.Context(), "memory tool search failed", "conversation_id", claims.ConversationID, "error", err)
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "MEMORY_TOOL_UNAVAILABLE", Message: "记忆检索暂时不可用,请继续普通交流", Retryable: true})
}
return
}
WriteJSON(writer, request, http.StatusOK, result)
})
}
+219
View File
@@ -0,0 +1,219 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/opsdevice"
)
func ListOpsDevicesHandler(service *opsdevice.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, tenantID, ok := opsDevicePrincipal(w, r)
if !ok {
return
}
items, err := service.List(r.Context(), principal, tenantID)
if err != nil {
writeOpsDeviceError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func ListOpsDeviceOptionsHandler(service *opsdevice.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, tenantID, ok := opsDevicePrincipal(w, r)
if !ok {
return
}
items, err := service.Options(r.Context(), principal, tenantID)
if err != nil {
writeOpsDeviceError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func CreateOpsDeviceHandler(service *opsdevice.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, tenantID, ok := opsDevicePrincipal(w, r)
if !ok {
return
}
var body struct {
Model string `json:"model"`
HardwareRevision string `json:"hardware_revision"`
MinimumSecurityVersion string `json:"minimum_security_version"`
MinimumFirmwareVersion string `json:"minimum_firmware_version"`
HouseholdID string `json:"household_id"`
SubjectID string `json:"subject_id"`
}
if !decodeWebJSON(w, r, &body, "设备登记参数不正确") {
return
}
if (body.HouseholdID != "" && !validUUID(body.HouseholdID)) || (body.SubjectID != "" && !validUUID(body.SubjectID)) {
writeValidation(w, r, "设备登记参数不正确", FieldError{Field: "assignment", Message: "家庭和长者必须是有效UUID"})
return
}
result, err := service.Create(r.Context(), principal, tenantID, RequestID(r.Context()), opsdevice.CreateInput{
Model: body.Model, HardwareRevision: body.HardwareRevision, MinimumSecurityVersion: body.MinimumSecurityVersion,
MinimumFirmwareVersion: body.MinimumFirmwareVersion, HouseholdID: body.HouseholdID, SubjectID: body.SubjectID,
})
if err != nil {
writeOpsDeviceError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("ETag", concurrency.FormatETag(result.Device.Version))
WriteJSON(w, r, http.StatusCreated, result)
})
}
func UpdateOpsDeviceHandler(service *opsdevice.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, tenantID, ok := opsDevicePrincipal(w, r)
if !ok {
return
}
if !validUUID(r.PathValue("id")) {
writeValidation(w, r, "设备编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
return
}
expected, err := concurrency.ParseIfMatch(r)
if err != nil {
writeETagError(w, r, err)
return
}
var body struct {
Model *string `json:"model"`
HardwareRevision *string `json:"hardware_revision"`
MinimumSecurityVersion *string `json:"minimum_security_version"`
MinimumFirmwareVersion *string `json:"minimum_firmware_version"`
Status *string `json:"status"`
HouseholdID *string `json:"household_id"`
SubjectID *string `json:"subject_id"`
}
if !decodeWebJSON(w, r, &body, "设备修改参数不正确") {
return
}
if (body.HouseholdID != nil && *body.HouseholdID != "" && !validUUID(*body.HouseholdID)) || (body.SubjectID != nil && *body.SubjectID != "" && !validUUID(*body.SubjectID)) {
writeValidation(w, r, "设备修改参数不正确", FieldError{Field: "assignment", Message: "家庭和长者必须是有效UUID"})
return
}
item, err := service.Update(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()), expected, opsdevice.UpdateInput{
Model: body.Model, HardwareRevision: body.HardwareRevision, MinimumSecurityVersion: body.MinimumSecurityVersion,
MinimumFirmwareVersion: body.MinimumFirmwareVersion, Status: body.Status, HouseholdID: body.HouseholdID, SubjectID: body.SubjectID,
})
if err != nil {
writeOpsDeviceError(w, r, err)
return
}
w.Header().Set("ETag", concurrency.FormatETag(item.Version))
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func DeleteOpsDeviceHandler(service *opsdevice.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, tenantID, ok := opsDevicePrincipal(w, r)
if !ok {
return
}
if !validUUID(r.PathValue("id")) {
writeValidation(w, r, "设备编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
return
}
expected, err := concurrency.ParseIfMatch(r)
if err != nil {
writeETagError(w, r, err)
return
}
item, err := service.Delete(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()), expected)
if err != nil {
writeOpsDeviceError(w, r, err)
return
}
w.Header().Set("ETag", concurrency.FormatETag(item.Version))
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func RotateOpsDeviceCredentialHandler(service *opsdevice.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, tenantID, ok := opsDevicePrincipal(w, r)
if !ok {
return
}
if !validUUID(r.PathValue("id")) {
writeValidation(w, r, "设备编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
return
}
item, err := service.RotateCredential(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()))
if err != nil {
writeOpsDeviceError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusCreated, item)
})
}
func IssueOpsDeviceClaimHandler(service *opsdevice.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, tenantID, ok := opsDevicePrincipal(w, r)
if !ok {
return
}
if !validUUID(r.PathValue("id")) {
writeValidation(w, r, "设备编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
return
}
item, err := service.IssueDeviceClaim(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()))
if err != nil {
writeOpsDeviceError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusCreated, item)
})
}
func opsDevicePrincipal(w http.ResponseWriter, r *http.Request) (principal identity.Principal, tenantID string, ok bool) {
principal, ok = webPrincipal(w, r)
if !ok {
return principal, "", false
}
tenantID = strings.TrimSpace(r.Header.Get("X-Tenant-Id"))
if !validUUID(tenantID) {
writeValidation(w, r, "运维设备请求参数不正确", FieldError{Field: "X-Tenant-Id", Message: "必须提供有效的客户组织UUID"})
return principal, "", false
}
return principal, tenantID, true
}
func writeOpsDeviceError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, opsdevice.ErrInvalid):
writeValidation(w, r, "设备管理参数不正确", FieldError{Field: "device", Message: "设备字段或家庭/长者归属不正确"})
case errors.Is(err, opsdevice.ErrAccessDenied):
WriteError(w, r, http.StatusForbidden, "OPS_ACCESS_DENIED", "仅独立运维账号可以管理设备")
case errors.Is(err, opsdevice.ErrNotFound):
WriteError(w, r, http.StatusNotFound, "RESOURCE_NOT_FOUND", "设备不存在")
case errors.Is(err, opsdevice.ErrVersionConflict):
WriteError(w, r, http.StatusPreconditionFailed, "VERSION_CONFLICT", "设备信息已变化,请刷新后重试")
case errors.Is(err, opsdevice.ErrClaimUnavailable):
WriteError(w, r, http.StatusConflict, "DEVICE_CLAIM_UNAVAILABLE", "只有已登记且尚未绑定的设备可以生成绑定码")
default:
WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "设备管理操作失败")
}
}
+23
View File
@@ -13,8 +13,10 @@ import (
"data-terminal-cloud/internal/health"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/media"
"data-terminal-cloud/internal/memorytool"
"data-terminal-cloud/internal/opsaccess"
"data-terminal-cloud/internal/opsconfig"
"data-terminal-cloud/internal/opsdevice"
"data-terminal-cloud/internal/saas"
"data-terminal-cloud/internal/sharing"
"data-terminal-cloud/internal/webevents"
@@ -39,6 +41,7 @@ type RouterOptions struct {
AlertService *alerts.Service
DiagnosticService *diagnostics.Service
OpsAccessService *opsaccess.Service
OpsDeviceService *opsdevice.Service
ConversationCreator ConversationCreator
ConversationActivator ConversationActivator
DeviceConversationStateReader DeviceConversationStateReader
@@ -51,6 +54,8 @@ type RouterOptions struct {
PlatformHealthCallbackSecret []byte
PlatformHealthCallbackSkew time.Duration
PlatformHealthProcessor PlatformHealthProcessor
MemoryToolService *memorytool.Service
MemoryToolSigner *memorytool.TokenSigner
}
func NewRouter(options RouterOptions) http.Handler {
@@ -77,6 +82,9 @@ func NewRouter(options RouterOptions) http.Handler {
mux.HandleFunc("GET /api/v1/version", func(writer http.ResponseWriter, request *http.Request) {
WriteJSON(writer, request, http.StatusOK, buildinfo.Current())
})
if options.ServiceName == "api" && options.MemoryToolService != nil && options.MemoryToolSigner != nil {
mux.Handle("POST /internal/v1/tools/memory-search", MemoryToolSearchHandler(options.MemoryToolService, options.MemoryToolSigner, options.RateLimiter, logger))
}
if options.ServiceName == "api" && options.TokenVerifier != nil && options.IdentityRepository != nil {
mux.Handle("GET /api/v1/me", BearerAuth(options.TokenVerifier, UserRateLimit(logger, options.RateLimiter, CurrentUserHandler(options.IdentityRepository))))
}
@@ -95,6 +103,7 @@ func NewRouter(options RouterOptions) http.Handler {
mux.Handle("PATCH /api/v1/sharing-grants/{id}/resource", authenticated(EditSharedStoryHandler(options.SharingService)))
}
if options.OpsConfigService != nil {
mux.Handle("GET /api/v1/ops/content-quality", authenticated(GetOpsContentQualityHandler(options.OpsConfigService)))
mux.Handle("GET /api/v1/ops/resource-references", authenticated(ListOpsResourceReferencesHandler(options.OpsConfigService)))
mux.Handle("POST /api/v1/ops/resource-references", authenticated(CreateOpsResourceReferenceHandler(options.OpsConfigService)))
mux.Handle("PATCH /api/v1/ops/resource-references/{id}/revoke", authenticated(RevokeOpsResourceReferenceHandler(options.OpsConfigService)))
@@ -112,12 +121,14 @@ func NewRouter(options RouterOptions) http.Handler {
mux.Handle("POST /api/v1/ops/config-versions/{id}/rollback", authenticated(RollbackOpsConfigVersionHandler(options.OpsConfigService)))
}
if options.BillingService != nil {
mux.Handle("GET /api/v1/households/{id}/usage-overview", authenticated(GetFamilyUsageOverviewHandler(options.BillingService)))
mux.Handle("GET /api/v1/ops/price-books", authenticated(ListOpsPriceBooksHandler(options.BillingService)))
mux.Handle("POST /api/v1/ops/price-books", authenticated(CreateOpsPriceBookHandler(options.BillingService)))
mux.Handle("PATCH /api/v1/ops/price-books/{id}/publish", authenticated(PublishOpsPriceBookHandler(options.BillingService)))
mux.Handle("GET /api/v1/ops/quota-policies", authenticated(ListOpsQuotaPoliciesHandler(options.BillingService)))
mux.Handle("POST /api/v1/ops/quota-policies", authenticated(CreateOpsQuotaPolicyHandler(options.BillingService)))
mux.Handle("GET /api/v1/ops/cost-summary", authenticated(GetOpsCostSummaryHandler(options.BillingService)))
mux.Handle("GET /api/v1/ops/usage-overview", authenticated(GetOpsUsageOverviewHandler(options.BillingService)))
}
if options.DiagnosticService != nil {
mux.Handle("GET /api/v1/ops/diagnostics/platform-health", authenticated(GetOpsPlatformHealthHandler(options.DiagnosticService)))
@@ -144,6 +155,15 @@ func NewRouter(options RouterOptions) http.Handler {
mux.Handle("GET /api/v1/ops/sensitive-conversations/{id}", authenticated(GetOpsSensitiveConversationHandler(options.OpsAccessService)))
mux.Handle("POST /api/v1/ops/sensitive-media/{id}/play", authenticated(CreateOpsSensitiveRecordingAccessHandler(options.OpsAccessService)))
}
if options.OpsDeviceService != nil {
mux.Handle("GET /api/v1/ops/devices", authenticated(ListOpsDevicesHandler(options.OpsDeviceService)))
mux.Handle("POST /api/v1/ops/devices", authenticated(CreateOpsDeviceHandler(options.OpsDeviceService)))
mux.Handle("GET /api/v1/ops/device-assignment-options", authenticated(ListOpsDeviceOptionsHandler(options.OpsDeviceService)))
mux.Handle("PATCH /api/v1/ops/devices/{id}", authenticated(UpdateOpsDeviceHandler(options.OpsDeviceService)))
mux.Handle("DELETE /api/v1/ops/devices/{id}", authenticated(DeleteOpsDeviceHandler(options.OpsDeviceService)))
mux.Handle("POST /api/v1/ops/devices/{id}/credentials/rotate", authenticated(RotateOpsDeviceCredentialHandler(options.OpsDeviceService)))
mux.Handle("POST /api/v1/ops/devices/{id}/claims", authenticated(IssueOpsDeviceClaimHandler(options.OpsDeviceService)))
}
mux.Handle("GET /api/v1/households", authenticated(ListHouseholdsHandler(options.SaaSService)))
mux.Handle("POST /api/v1/households", authenticated(CreateHouseholdHandler(options.SaaSService)))
mux.Handle("GET /api/v1/households/{id}", authenticated(GetHouseholdHandler(options.SaaSService)))
@@ -171,6 +191,8 @@ func NewRouter(options RouterOptions) http.Handler {
mux.Handle("POST /api/v1/device-claims", authenticated(ClaimDeviceHandler(options.SaaSService)))
mux.Handle("GET /api/v1/devices", authenticated(ListManagedDevicesHandler(options.SaaSService)))
mux.Handle("GET /api/v1/devices/{id}", authenticated(GetManagedDeviceHandler(options.SaaSService)))
mux.Handle("GET /api/v1/devices/{id}/binding-impact", authenticated(GetDeviceBindingImpactHandler(options.SaaSService)))
mux.Handle("POST /api/v1/devices/{id}/binding-change", authenticated(ChangeDeviceBindingHandler(options.SaaSService)))
mux.Handle("GET /api/v1/devices/{id}/presence", authenticated(GetDevicePresenceHandler(options.SaaSService)))
mux.Handle("GET /api/v1/devices/{id}/twin", authenticated(GetDeviceTwinHandler(options.SaaSService)))
mux.Handle("PATCH /api/v1/devices/{id}/twin", authenticated(PatchDeviceTwinHandler(options.SaaSService)))
@@ -207,6 +229,7 @@ func NewRouter(options RouterOptions) http.Handler {
mux.Handle("GET /api/v1/subjects/{id}/stories", authenticated(ListSubjectStoriesHandler(options.SaaSService)))
mux.Handle("GET /api/v1/stories/{id}", authenticated(GetStoryHandler(options.SaaSService)))
mux.Handle("PATCH /api/v1/stories/{id}", authenticated(PatchStoryHandler(options.SaaSService)))
mux.Handle("PATCH /api/v1/stories/{id}/facts/{fact_id}", authenticated(UpdateFactCandidateHandler(options.SaaSService)))
mux.Handle("POST /api/v1/stories/{id}/confirm", authenticated(ConfirmStoryHandler(options.SaaSService)))
mux.Handle("POST /api/v1/stories/{id}/reject", authenticated(RejectStoryHandler(options.SaaSService)))
mux.Handle("GET /api/v1/subjects/{id}/memories", authenticated(ListSubjectMemoriesHandler(options.SaaSService)))
+268 -12
View File
@@ -729,6 +729,9 @@ func ClaimDeviceHandler(service *saas.Service) http.Handler {
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"})
}
@@ -766,6 +769,86 @@ func GetManagedDeviceHandler(service *saas.Service) http.Handler {
})
}
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")
@@ -1009,21 +1092,104 @@ func PatchStoryHandler(service *saas.Service) http.Handler {
return
}
var input struct {
Content string `json:"content"`
Reason string `json:"reason"`
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
}
result, err := service.ReviseStory(request.Context(), principal, request.PathValue("id"), input.Content, input.Reason, RequestID(request.Context()), expected)
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
@@ -1053,16 +1219,22 @@ func storyDecisionHandler(service *saas.Service, confirm bool) http.Handler {
return
}
var input struct {
Reason string `json:"reason"`
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 {
@@ -1073,7 +1245,7 @@ func storyDecisionHandler(service *saas.Service, confirm bool) http.Handler {
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)
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
@@ -1131,9 +1303,10 @@ func PatchMemoryHandler(service *saas.Service) http.Handler {
return
}
var input struct {
Content *string `json:"content"`
Status *string `json:"status"`
Reason string `json:"reason"`
Content *string `json:"content"`
Status *string `json:"status"`
ErrorType *string `json:"error_type"`
Reason string `json:"reason"`
}
if !decodeWebJSON(writer, request, &input, "记忆修改参数不正确") {
return
@@ -1153,6 +1326,13 @@ func PatchMemoryHandler(service *saas.Service) http.Handler {
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字符"})
}
@@ -1160,7 +1340,7 @@ func PatchMemoryHandler(service *saas.Service) http.Handler {
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, Reason: input.Reason})
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
@@ -1233,15 +1413,23 @@ func CreatePrivacyRequestHandler(service *saas.Service) http.Handler {
Reason string `json:"reason"`
Confirmation string `json:"confirmation"`
Scope struct {
Categories []string `json:"categories"`
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 != "DELETE" {
writeValidation(writer, request, "隐私请求参数不正确", FieldError{Field: "request_type", Message: "必须为ACCESS或DELETE"})
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" {
@@ -1274,6 +1462,68 @@ func CreatePrivacyRequestHandler(service *saas.Service) http.Handler {
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)
@@ -1633,6 +1883,10 @@ func writeSaaSError(writer http.ResponseWriter, request *http.Request, err error
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):
@@ -1665,6 +1919,8 @@ func writeSaaSError(writer http.ResponseWriter, request *http.Request, err error
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, pagination.ErrInvalidCursor):
writeValidation(writer, request, "分页游标无效", FieldError{Field: "cursor", Message: "游标无效、已损坏或不是本服务签发"})
default:
+9
View File
@@ -103,6 +103,15 @@ func TestDecodeAndResourcePrincipal(t *testing.T) {
}
}
func TestClaimDeviceRejectsDeviceIdentifierAsClaimCode(t *testing.T) {
request := authenticatedRequest(http.MethodPost, "/api/v1/device-claims", `{"claim_code":"10000000-0000-0000-0000-000000000512","household_id":"10000000-0000-0000-0000-000000000211","subject_id":"10000000-0000-0000-0000-000000000411"}`, identity.Principal{Issuer: "test", Subject: "owner"})
recorder := httptest.NewRecorder()
ClaimDeviceHandler(nil).ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "不能填写设备编号") {
t.Fatalf("device identifier claim status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestSaaSErrorAndETagMapping(t *testing.T) {
tests := []struct {
err error