chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Agent interface {
|
||||
Base
|
||||
RegisterUnAuthRouter(r *gin.RouterGroup)
|
||||
RegisterAuthUserRouter(r *gin.RouterGroup)
|
||||
RegisterAuthAdminRouter(r *gin.RouterGroup)
|
||||
}
|
||||
|
||||
var (
|
||||
CallAgent,
|
||||
registerAgent = MakePlugin[Agent](true)
|
||||
siteURLFn func() string
|
||||
)
|
||||
|
||||
// SiteURL The site url is the domain address of the current site. e.g. http://localhost:8080
|
||||
// When some Agent plugins want to redirect to the origin site, it can use this function to get the site url.
|
||||
func SiteURL() string {
|
||||
if siteURLFn != nil {
|
||||
return siteURLFn()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RegisterGetSiteURLFunc Register a function to get the site url.
|
||||
func RegisterGetSiteURLFunc(fn func() string) {
|
||||
siteURLFn = fn
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
// Info presents the plugin information
|
||||
type Info struct {
|
||||
Name Translator
|
||||
SlugName string
|
||||
Description Translator
|
||||
Author string
|
||||
Version string
|
||||
Link string
|
||||
}
|
||||
|
||||
// Base is the base plugin
|
||||
type Base interface {
|
||||
// Info returns the plugin information
|
||||
Info() Info
|
||||
}
|
||||
|
||||
var (
|
||||
// CallBase is a function that calls all registered base plugins
|
||||
CallBase,
|
||||
registerBase = MakePlugin[Base](true)
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Cache interface {
|
||||
Base
|
||||
|
||||
GetString(ctx context.Context, key string) (data string, exist bool, err error)
|
||||
SetString(ctx context.Context, key, value string, ttl time.Duration) (err error)
|
||||
GetInt64(ctx context.Context, key string) (data int64, exist bool, err error)
|
||||
SetInt64(ctx context.Context, key string, value int64, ttl time.Duration) (err error)
|
||||
Increase(ctx context.Context, key string, value int64) (data int64, err error)
|
||||
Decrease(ctx context.Context, key string, value int64) (data int64, err error)
|
||||
Del(ctx context.Context, key string) (err error)
|
||||
Flush(ctx context.Context) (err error)
|
||||
}
|
||||
|
||||
var (
|
||||
// CallCache is a function that calls all registered cache
|
||||
CallCache,
|
||||
registerCache = MakePlugin[Cache](false)
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
type Captcha interface {
|
||||
Base
|
||||
// GetConfig required. Get the captcha plugin configuration.
|
||||
// The configuration is used to generate the captcha for frontend. Such as the token for third-party service.
|
||||
GetConfig() (configJsonStr string)
|
||||
// Create optional. If this plugin need to create captcha via backend, implement this method.
|
||||
// On other hand, if this plugin create captcha via third-party service, ignore this method.
|
||||
// Return captcha: The captcha image base64 string, code: The real captcha code.
|
||||
Create() (captcha, code string)
|
||||
// Verify required. Verify the user input captcha is correct or not
|
||||
// captcha: The captchaCode generated by Create method, if not implemented, it's empty.
|
||||
Verify(captchaCode, userInput string) (pass bool)
|
||||
}
|
||||
|
||||
var (
|
||||
// CallCaptcha is a function that calls all registered parsers
|
||||
callCaptcha,
|
||||
registerCaptcha = MakePlugin[Captcha](false)
|
||||
)
|
||||
|
||||
func CallCaptcha(fn func(fn Captcha) error) error {
|
||||
slugName := ""
|
||||
_ = callCaptcha(func(captcha Captcha) error {
|
||||
slugName = captcha.Info().SlugName
|
||||
return nil
|
||||
})
|
||||
if slugName == "" {
|
||||
return nil
|
||||
}
|
||||
return callCaptcha(func(captcha Captcha) error {
|
||||
if captcha.Info().SlugName == slugName {
|
||||
return fn(captcha)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func CaptchaEnabled() (enabled bool) {
|
||||
_ = callCaptcha(func(fn Captcha) error {
|
||||
enabled = true
|
||||
return nil
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func coordinatedCaptchaPlugins(slugName string) (enabledSlugNames []string) {
|
||||
isCaptcha := false
|
||||
_ = callCaptcha(func(captcha Captcha) error {
|
||||
name := captcha.Info().SlugName
|
||||
if slugName == name {
|
||||
isCaptcha = true
|
||||
} else {
|
||||
enabledSlugNames = append(enabledSlugNames, name)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if isCaptcha {
|
||||
return enabledSlugNames
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
var (
|
||||
DefaultCDNFileType = map[string]bool{
|
||||
".ico": true,
|
||||
".json": true,
|
||||
".css": true,
|
||||
".js": true,
|
||||
".webp": true,
|
||||
".woff2": true,
|
||||
".woff": true,
|
||||
".jpg": true,
|
||||
".svg": true,
|
||||
".png": true,
|
||||
".map": true,
|
||||
".txt": true,
|
||||
}
|
||||
)
|
||||
|
||||
type CDN interface {
|
||||
Base
|
||||
GetStaticPrefix() string
|
||||
}
|
||||
|
||||
var (
|
||||
// CallCDN is a function that calls all registered parsers
|
||||
CallCDN,
|
||||
registerCDN = MakePlugin[CDN](false)
|
||||
)
|
||||
|
||||
func coordinatedCDNPlugins(slugName string) (enabledSlugNames []string) {
|
||||
isCDN := false
|
||||
_ = CallCDN(func(cdn CDN) error {
|
||||
name := cdn.Info().SlugName
|
||||
if slugName == name {
|
||||
isCDN = true
|
||||
} else {
|
||||
enabledSlugNames = append(enabledSlugNames, name)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if isCDN {
|
||||
return enabledSlugNames
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
type ConfigType string
|
||||
type InputType string
|
||||
|
||||
const (
|
||||
ConfigTypeInput ConfigType = "input"
|
||||
ConfigTypeTextarea ConfigType = "textarea"
|
||||
ConfigTypeCheckbox ConfigType = "checkbox"
|
||||
ConfigTypeRadio ConfigType = "radio"
|
||||
ConfigTypeSelect ConfigType = "select"
|
||||
ConfigTypeUpload ConfigType = "upload"
|
||||
ConfigTypeTimezone ConfigType = "timezone"
|
||||
ConfigTypeSwitch ConfigType = "switch"
|
||||
ConfigTypeButton ConfigType = "button"
|
||||
ConfigTypeLegend ConfigType = "legend"
|
||||
ConfigTypeTagSelector ConfigType = "tag_selector"
|
||||
)
|
||||
|
||||
const (
|
||||
InputTypeText InputType = "text"
|
||||
InputTypeColor InputType = "color"
|
||||
InputTypeDate InputType = "date"
|
||||
InputTypeDatetime InputType = "datetime-local"
|
||||
InputTypeEmail InputType = "email"
|
||||
InputTypeMonth InputType = "month"
|
||||
InputTypeNumber InputType = "number"
|
||||
InputTypePassword InputType = "password"
|
||||
InputTypeRange InputType = "range"
|
||||
InputTypeSearch InputType = "search"
|
||||
InputTypeTel InputType = "tel"
|
||||
InputTypeTime InputType = "time"
|
||||
InputTypeUrl InputType = "url"
|
||||
InputTypeWeek InputType = "week"
|
||||
)
|
||||
|
||||
type ConfigField struct {
|
||||
Name string `json:"name"`
|
||||
Type ConfigType `json:"type"`
|
||||
Title Translator `json:"title"`
|
||||
Description Translator `json:"description"`
|
||||
Required bool `json:"required"`
|
||||
Value any `json:"value"`
|
||||
UIOptions ConfigFieldUIOptions `json:"ui_options"`
|
||||
Options []ConfigFieldOption `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type ConfigFieldUIOptions struct {
|
||||
Placeholder Translator `json:"placeholder"`
|
||||
Rows string `json:"rows,omitempty"`
|
||||
InputType InputType `json:"input_type,omitempty"`
|
||||
Label Translator `json:"label"`
|
||||
Action *UIOptionAction `json:"action,omitempty"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
Text Translator `json:"text"`
|
||||
ClassName string `json:"class_name,omitempty"`
|
||||
FieldClassName string `json:"field_class_name,omitempty"`
|
||||
}
|
||||
|
||||
type ConfigFieldOption struct {
|
||||
Label Translator `json:"label"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type UIOptionAction struct {
|
||||
Url string `json:"url"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Loading *LoadingAction `json:"loading,omitempty"`
|
||||
OnComplete *OnCompleteAction `json:"on_complete,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
LoadingActionStateNone LoadingActionType = "none"
|
||||
LoadingActionStatePending LoadingActionType = "pending"
|
||||
LoadingActionStateComplete LoadingActionType = "completed"
|
||||
)
|
||||
|
||||
type LoadingActionType string
|
||||
|
||||
type LoadingAction struct {
|
||||
Text Translator `json:"text"`
|
||||
State LoadingActionType `json:"state"`
|
||||
}
|
||||
|
||||
type OnCompleteAction struct {
|
||||
ToastReturnMessage bool `json:"toast_return_message"`
|
||||
RefreshFormConfig bool `json:"refresh_form_config"`
|
||||
}
|
||||
|
||||
// TagSelectorOption represents a tag option in the tag selector config value field
|
||||
type TagSelectorOption struct {
|
||||
TagID string `json:"tag_id"`
|
||||
SlugName string `json:"slug_name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Recommend bool `json:"recommend"`
|
||||
Reserved bool `json:"reserved"`
|
||||
}
|
||||
|
||||
type Config interface {
|
||||
Base
|
||||
|
||||
// ConfigFields returns the list of config fields
|
||||
ConfigFields() []ConfigField
|
||||
|
||||
// ConfigReceiver receives the config data, it calls when the config is saved or initialized.
|
||||
// We recommend to unmarshal the data to a struct, and then use the struct to do something.
|
||||
// The config is encoded in JSON format.
|
||||
// It depends on the definition of ConfigFields.
|
||||
ConfigReceiver(config []byte) error
|
||||
}
|
||||
|
||||
var (
|
||||
// CallConfig is a function that calls all registered config plugins
|
||||
CallConfig,
|
||||
registerConfig = MakePlugin[Config](true)
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
type Connector interface {
|
||||
Base
|
||||
|
||||
// ConnectorLogoSVG presents the logo in svg format
|
||||
ConnectorLogoSVG() string
|
||||
|
||||
// ConnectorName presents the name of the connector
|
||||
// e.g. Facebook, Twitter, Instagram
|
||||
ConnectorName() Translator
|
||||
|
||||
// ConnectorSlugName presents the slug name of the connector
|
||||
// Please use lowercase and hyphen as the separator
|
||||
// e.g. facebook, twitter, instagram
|
||||
ConnectorSlugName() string
|
||||
|
||||
// ConnectorSender presents the sender of the connector
|
||||
// It handles the start endpoint of the connector
|
||||
// receiverURL is the whole URL of the receiver
|
||||
ConnectorSender(ctx *GinContext, receiverURL string) (redirectURL string)
|
||||
|
||||
// ConnectorReceiver presents the receiver of the connector
|
||||
// It handles the callback endpoint of the connector, and returns the
|
||||
ConnectorReceiver(ctx *GinContext, receiverURL string) (userInfo ExternalLoginUserInfo, err error)
|
||||
}
|
||||
|
||||
// ExternalLoginUserInfo external login user info
|
||||
type ExternalLoginUserInfo struct {
|
||||
// required. The unique user ID provided by the third-party login
|
||||
ExternalID string
|
||||
// optional. This name is used preferentially during registration
|
||||
DisplayName string
|
||||
// optional. This username is used preferentially during registration
|
||||
Username string
|
||||
// optional. If email exist will bind the existing user
|
||||
// IMPORTANT: The email must have been verified. If the plugin can't guarantee the email is verified, please leave it empty.
|
||||
Email string
|
||||
// optional. The avatar URL provided by the third-party login platform
|
||||
Avatar string
|
||||
// optional. The original user information provided by the third-party login platform
|
||||
MetaInfo string
|
||||
}
|
||||
|
||||
var (
|
||||
// CallConnector is a function that calls all registered connectors
|
||||
CallConnector,
|
||||
registerConnector = MakePlugin[Connector](false)
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
type EmbedConfig struct {
|
||||
Platform string `json:"platform"`
|
||||
Enable bool `json:"enable"`
|
||||
}
|
||||
|
||||
type Embed interface {
|
||||
Base
|
||||
GetEmbedConfigs(ctx *gin.Context) (embedConfigs []*EmbedConfig, err error)
|
||||
}
|
||||
|
||||
var (
|
||||
// CallEmbed is a function that calls all registered parsers
|
||||
CallEmbed,
|
||||
registerEmbed = MakePlugin[Embed](false)
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
type Filter interface {
|
||||
Base
|
||||
FilterText(text string) (err error)
|
||||
}
|
||||
|
||||
var (
|
||||
// CallFilter is a function that calls all registered parsers
|
||||
CallFilter,
|
||||
registerFilter = MakePlugin[Filter](false)
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type QuestionImporterInfo struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Tags []string `json:"tags"`
|
||||
UserEmail string `json:"user_email"`
|
||||
}
|
||||
|
||||
type Importer interface {
|
||||
Base
|
||||
RegisterImporterFunc(ctx context.Context, importer ImporterFunc)
|
||||
}
|
||||
|
||||
type ImporterFunc interface {
|
||||
AddQuestion(ctx context.Context, questionInfo QuestionImporterInfo) (err error)
|
||||
}
|
||||
|
||||
var (
|
||||
// CallImporter is a function that calls all registered parsers
|
||||
CallImporter,
|
||||
registerImporter = MakePlugin[Importer](false)
|
||||
)
|
||||
|
||||
func ImporterEnabled() (enabled bool) {
|
||||
_ = CallImporter(func(fn Importer) error {
|
||||
enabled = true
|
||||
return nil
|
||||
})
|
||||
return
|
||||
}
|
||||
func GetImporter() (ip Importer, ok bool) {
|
||||
_ = CallImporter(func(fn Importer) error {
|
||||
ip = fn
|
||||
ok = true
|
||||
return nil
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/segmentfault/pacman/cache"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// Error variables for KV storage operations
|
||||
var (
|
||||
// ErrKVKeyNotFound is returned when the requested key does not exist in the KV storage
|
||||
ErrKVKeyNotFound = fmt.Errorf("key not found in KV storage")
|
||||
// ErrKVGroupEmpty is returned when a required group name is empty
|
||||
ErrKVGroupEmpty = fmt.Errorf("group name is empty")
|
||||
// ErrKVKeyEmpty is returned when a required key name is empty
|
||||
ErrKVKeyEmpty = fmt.Errorf("key name is empty")
|
||||
// ErrKVKeyAndGroupEmpty is returned when both key and group names are empty
|
||||
ErrKVKeyAndGroupEmpty = fmt.Errorf("both key and group are empty")
|
||||
// ErrKVTransactionFailed is returned when a KV storage transaction operation fails
|
||||
ErrKVTransactionFailed = fmt.Errorf("KV storage transaction failed")
|
||||
)
|
||||
|
||||
// KVParams is the parameters for KV storage operations
|
||||
type KVParams struct {
|
||||
Group string
|
||||
Key string
|
||||
Value string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// KVOperator provides methods to interact with the key-value storage system for plugins
|
||||
type KVOperator struct {
|
||||
data *Data
|
||||
session *xorm.Session
|
||||
pluginSlugName string
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
// KVStorageOption defines a function type that configures a KVOperator
|
||||
type KVStorageOption func(*KVOperator)
|
||||
|
||||
// WithCacheTTL is the option to set the cache TTL; the default value is 30 minutes.
|
||||
// If ttl is less than 0, the cache will not be used
|
||||
func WithCacheTTL(ttl time.Duration) KVStorageOption {
|
||||
return func(kv *KVOperator) {
|
||||
kv.cacheTTL = ttl
|
||||
}
|
||||
}
|
||||
|
||||
// Option is used to set the options for the KV storage
|
||||
func (kv *KVOperator) Option(opts ...KVStorageOption) {
|
||||
for _, opt := range opts {
|
||||
opt(kv)
|
||||
}
|
||||
}
|
||||
|
||||
func (kv *KVOperator) getSession(ctx context.Context) (*xorm.Session, func()) {
|
||||
session := kv.session
|
||||
cleanup := func() {}
|
||||
if session == nil {
|
||||
session = kv.data.DB.NewSession().Context(ctx)
|
||||
cleanup = func() {
|
||||
if session != nil {
|
||||
_ = session.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
return session, cleanup
|
||||
}
|
||||
|
||||
func (kv *KVOperator) getCacheKey(params KVParams) string {
|
||||
return fmt.Sprintf("plugin_kv_storage:%s:group:%s:key:%s", kv.pluginSlugName, params.Group, params.Key)
|
||||
}
|
||||
|
||||
func (kv *KVOperator) setCache(ctx context.Context, params KVParams) {
|
||||
if kv.cacheTTL < 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ttl := kv.cacheTTL
|
||||
if ttl > 10 {
|
||||
ttl += time.Duration(float64(ttl) * 0.1 * (1 - rand.Float64()))
|
||||
}
|
||||
|
||||
cacheKey := kv.getCacheKey(params)
|
||||
if err := kv.data.Cache.SetString(ctx, cacheKey, params.Value, ttl); err != nil {
|
||||
log.Warnf("cache set failed: %v, key: %s", err, cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
func (kv *KVOperator) getCache(ctx context.Context, params KVParams) (string, bool, error) {
|
||||
if kv.cacheTTL < 0 {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
cacheKey := kv.getCacheKey(params)
|
||||
return kv.data.Cache.GetString(ctx, cacheKey)
|
||||
}
|
||||
|
||||
func (kv *KVOperator) cleanCache(ctx context.Context, params KVParams) {
|
||||
if kv.cacheTTL < 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err := kv.data.Cache.Del(ctx, kv.getCacheKey(params)); err != nil {
|
||||
log.Warnf("Failed to delete cache for key %s: %v", params.Key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a value from KV storage by group and key.
|
||||
// Returns the value as a string or an error if the key is not found.
|
||||
func (kv *KVOperator) Get(ctx context.Context, params KVParams) (string, error) {
|
||||
if params.Key == "" {
|
||||
return "", ErrKVKeyEmpty
|
||||
}
|
||||
|
||||
if value, exist, err := kv.getCache(ctx, params); err == nil && exist {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// query
|
||||
data := entity.PluginKVStorage{}
|
||||
query, cleanup := kv.getSession(ctx)
|
||||
defer cleanup()
|
||||
|
||||
query.Where(builder.Eq{
|
||||
"plugin_slug_name": kv.pluginSlugName,
|
||||
"`group`": params.Group,
|
||||
"`key`": params.Key,
|
||||
})
|
||||
|
||||
has, err := query.Get(&data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !has {
|
||||
return "", ErrKVKeyNotFound
|
||||
}
|
||||
|
||||
params.Value = data.Value
|
||||
kv.setCache(ctx, params)
|
||||
|
||||
return data.Value, nil
|
||||
}
|
||||
|
||||
// Set stores a value in KV storage with the specified group and key.
|
||||
// Updates the value if it already exists.
|
||||
func (kv *KVOperator) Set(ctx context.Context, params KVParams) error {
|
||||
if params.Key == "" {
|
||||
return ErrKVKeyEmpty
|
||||
}
|
||||
|
||||
query, cleanup := kv.getSession(ctx)
|
||||
defer cleanup()
|
||||
|
||||
data := &entity.PluginKVStorage{
|
||||
PluginSlugName: kv.pluginSlugName,
|
||||
Group: params.Group,
|
||||
Key: params.Key,
|
||||
Value: params.Value,
|
||||
}
|
||||
|
||||
kv.cleanCache(ctx, params)
|
||||
|
||||
affected, err := query.Where(builder.Eq{
|
||||
"plugin_slug_name": kv.pluginSlugName,
|
||||
"`group`": params.Group,
|
||||
"`key`": params.Key,
|
||||
}).Cols("value").Update(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if affected == 0 {
|
||||
_, err = query.Insert(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Del removes values from KV storage by group and/or key.
|
||||
// If both group and key are provided, only that specific entry is deleted.
|
||||
// If only group is provided, all entries in that group are deleted.
|
||||
// At least one of group or key must be provided.
|
||||
func (kv *KVOperator) Del(ctx context.Context, params KVParams) error {
|
||||
if params.Key == "" && params.Group == "" {
|
||||
return ErrKVKeyAndGroupEmpty
|
||||
}
|
||||
|
||||
kv.cleanCache(ctx, params)
|
||||
|
||||
session, cleanup := kv.getSession(ctx)
|
||||
defer cleanup()
|
||||
|
||||
session.Where(builder.Eq{
|
||||
"plugin_slug_name": kv.pluginSlugName,
|
||||
})
|
||||
if params.Group != "" {
|
||||
session.Where(builder.Eq{"`group`": params.Group})
|
||||
}
|
||||
if params.Key != "" {
|
||||
session.Where(builder.Eq{"`key`": params.Key})
|
||||
}
|
||||
|
||||
_, err := session.Delete(&entity.PluginKVStorage{})
|
||||
return err
|
||||
}
|
||||
|
||||
// GetByGroup retrieves all key-value pairs for a specific group with pagination support.
|
||||
// Returns a map of keys to values or an error if the group is empty or not found.
|
||||
func (kv *KVOperator) GetByGroup(ctx context.Context, params KVParams) (map[string]string, error) {
|
||||
if params.Group == "" {
|
||||
return nil, ErrKVGroupEmpty
|
||||
}
|
||||
|
||||
if params.Page < 1 {
|
||||
params.Page = 1
|
||||
}
|
||||
if params.PageSize < 1 {
|
||||
params.PageSize = 10
|
||||
}
|
||||
|
||||
query, cleanup := kv.getSession(ctx)
|
||||
defer cleanup()
|
||||
|
||||
var items []entity.PluginKVStorage
|
||||
err := query.Where(builder.Eq{"plugin_slug_name": kv.pluginSlugName, "`group`": params.Group}).
|
||||
Limit(params.PageSize, (params.Page-1)*params.PageSize).
|
||||
OrderBy("id ASC").
|
||||
Find(&items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]string, len(items))
|
||||
for _, item := range items {
|
||||
result[item.Key] = item.Value
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Tx executes a function within a transaction context. If the KVOperator already has a session,
|
||||
// it will use that session. Otherwise, it creates a new transaction session.
|
||||
// The transaction will be committed if the function returns nil, or rolled back if it returns an error.
|
||||
func (kv *KVOperator) Tx(ctx context.Context, fn func(ctx context.Context, kv *KVOperator) error) error {
|
||||
var (
|
||||
txKv = kv
|
||||
shouldCommit bool
|
||||
)
|
||||
|
||||
if kv.session == nil {
|
||||
session := kv.data.DB.NewSession().Context(ctx)
|
||||
if err := session.Begin(); err != nil {
|
||||
_ = session.Close()
|
||||
return fmt.Errorf("%w: begin transaction failed: %v", ErrKVTransactionFailed, err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if !shouldCommit {
|
||||
if rollbackErr := session.Rollback(); rollbackErr != nil {
|
||||
log.Errorf("rollback failed: %v", rollbackErr)
|
||||
}
|
||||
}
|
||||
_ = session.Close()
|
||||
}()
|
||||
|
||||
txKv = &KVOperator{
|
||||
session: session,
|
||||
data: kv.data,
|
||||
pluginSlugName: kv.pluginSlugName,
|
||||
}
|
||||
shouldCommit = true
|
||||
}
|
||||
|
||||
if err := fn(ctx, txKv); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrKVTransactionFailed, err)
|
||||
}
|
||||
|
||||
if shouldCommit {
|
||||
if err := txKv.session.Commit(); err != nil {
|
||||
return fmt.Errorf("%w: commit failed: %v", ErrKVTransactionFailed, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// KVStorage defines the interface for plugins that need data storage capabilities
|
||||
type KVStorage interface {
|
||||
Info() Info
|
||||
SetOperator(operator *KVOperator)
|
||||
}
|
||||
|
||||
var (
|
||||
CallKVStorage,
|
||||
registerKVStorage = MakePlugin[KVStorage](true)
|
||||
)
|
||||
|
||||
// NewKVOperator creates a new KV storage operator with the specified database engine, cache and plugin name.
|
||||
// It returns a KVOperator instance that can be used to interact with the plugin's storage.
|
||||
func NewKVOperator(db *xorm.Engine, cache cache.Cache, pluginSlugName string) *KVOperator {
|
||||
return &KVOperator{
|
||||
data: &Data{DB: db, Cache: cache},
|
||||
pluginSlugName: pluginSlugName,
|
||||
cacheTTL: 30 * time.Minute,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
// NotificationType is the type of the notification
|
||||
type NotificationType string
|
||||
|
||||
const (
|
||||
NotificationUpdateQuestion NotificationType = "notification.action.update_question"
|
||||
NotificationAnswerTheQuestion NotificationType = "notification.action.answer_the_question"
|
||||
NotificationUpVotedTheQuestion NotificationType = "notification.action.up_voted_question"
|
||||
NotificationDownVotedTheQuestion NotificationType = "notification.action.down_voted_question"
|
||||
NotificationUpdateAnswer NotificationType = "notification.action.update_answer"
|
||||
NotificationAcceptAnswer NotificationType = "notification.action.accept_answer"
|
||||
NotificationUpVotedTheAnswer NotificationType = "notification.action.up_voted_answer"
|
||||
NotificationDownVotedTheAnswer NotificationType = "notification.action.down_voted_answer"
|
||||
NotificationCommentQuestion NotificationType = "notification.action.comment_question"
|
||||
NotificationCommentAnswer NotificationType = "notification.action.comment_answer"
|
||||
NotificationUpVotedTheComment NotificationType = "notification.action.up_voted_comment"
|
||||
NotificationReplyToYou NotificationType = "notification.action.reply_to_you"
|
||||
NotificationMentionYou NotificationType = "notification.action.mention_you"
|
||||
NotificationYourQuestionIsClosed NotificationType = "notification.action.your_question_is_closed"
|
||||
NotificationYourQuestionWasDeleted NotificationType = "notification.action.your_question_was_deleted"
|
||||
NotificationYourAnswerWasDeleted NotificationType = "notification.action.your_answer_was_deleted"
|
||||
NotificationYourCommentWasDeleted NotificationType = "notification.action.your_comment_was_deleted"
|
||||
NotificationInvitedYouToAnswer NotificationType = "notification.action.invited_you_to_answer"
|
||||
NotificationNewQuestion NotificationType = "notification.action.new_question"
|
||||
NotificationNewQuestionFollowedTag NotificationType = "notification.action.new_question_followed_tag"
|
||||
)
|
||||
|
||||
type Notification interface {
|
||||
Base
|
||||
|
||||
// GetNewQuestionSubscribers returns the subscribers of the new question notification
|
||||
GetNewQuestionSubscribers() (userIDs []string)
|
||||
|
||||
// Notify sends a notification to the user
|
||||
Notify(msg NotificationMessage)
|
||||
}
|
||||
|
||||
type NotificationMessage struct {
|
||||
// the type of the notification
|
||||
Type NotificationType `json:"notification_type"`
|
||||
// the receiver user id
|
||||
ReceiverUserID string `json:"receiver_user_id"`
|
||||
// the receiver user using language
|
||||
ReceiverLang string `json:"receiver_lang"`
|
||||
// the receiver user external id (optional)
|
||||
ReceiverExternalID string `json:"receiver_external_id"`
|
||||
|
||||
// Who triggered the notification (optional, admin or system operation will not have this field)
|
||||
TriggerUserID string `json:"trigger_user_id"`
|
||||
// The trigger user's display name (optional, admin or system operation will not have this field)
|
||||
TriggerUserDisplayName string `json:"trigger_user_display_name"`
|
||||
// The trigger user's url (optional, admin or system operation will not have this field)
|
||||
TriggerUserUrl string `json:"trigger_user_url"`
|
||||
|
||||
// the question title
|
||||
QuestionTitle string `json:"question_title"`
|
||||
// the question url
|
||||
QuestionUrl string `json:"question_url"`
|
||||
// the question tags (comma separated, optional, only for new question notification)
|
||||
QuestionTags string `json:"tags"`
|
||||
|
||||
// the answer url (optional, only for new answer notification)
|
||||
AnswerUrl string `json:"answer_url"`
|
||||
// the comment url (optional, only for new comment notification)
|
||||
CommentUrl string `json:"comment_url"`
|
||||
}
|
||||
|
||||
var (
|
||||
// CallNotification is a function that calls all registered notification plugins
|
||||
CallNotification,
|
||||
registerNotification = MakePlugin[Notification](false)
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
type Parser interface {
|
||||
Base
|
||||
Parse(text string) (string, error)
|
||||
}
|
||||
|
||||
var (
|
||||
// CallParser is a function that calls all registered parsers
|
||||
CallParser,
|
||||
registerParser = MakePlugin[Parser](false)
|
||||
)
|
||||
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
|
||||
"github.com/segmentfault/pacman/cache"
|
||||
"github.com/segmentfault/pacman/i18n"
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/apache/answer/internal/base/handler"
|
||||
"github.com/apache/answer/internal/base/translator"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Data is defined here to avoid circular dependency with internal/base/data
|
||||
type Data struct {
|
||||
DB *xorm.Engine
|
||||
Cache cache.Cache
|
||||
}
|
||||
|
||||
// GinContext is a wrapper of gin.Context
|
||||
// We export it to make it easy to use in plugins
|
||||
type GinContext = gin.Context
|
||||
|
||||
// StatusManager is a manager that manages the status of plugins
|
||||
// Init Plugins:
|
||||
// json.Unmarshal([]byte(`{"plugin1": true, "plugin2": false}`), &plugin.StatusManager)
|
||||
// Dump Status:
|
||||
// json.Marshal(plugin.StatusManager)
|
||||
var StatusManager = statusManager{
|
||||
status: make(map[string]bool),
|
||||
}
|
||||
|
||||
// Register registers a plugin
|
||||
func Register(p Base) {
|
||||
registerBase(p)
|
||||
|
||||
if _, ok := p.(Config); ok {
|
||||
registerConfig(p.(Config))
|
||||
}
|
||||
|
||||
if _, ok := p.(UserConfig); ok {
|
||||
registerUserConfig(p.(UserConfig))
|
||||
}
|
||||
|
||||
if _, ok := p.(Connector); ok {
|
||||
registerConnector(p.(Connector))
|
||||
}
|
||||
|
||||
if _, ok := p.(Parser); ok {
|
||||
registerParser(p.(Parser))
|
||||
}
|
||||
|
||||
if _, ok := p.(Filter); ok {
|
||||
registerFilter(p.(Filter))
|
||||
}
|
||||
|
||||
if _, ok := p.(Storage); ok {
|
||||
registerStorage(p.(Storage))
|
||||
}
|
||||
|
||||
if _, ok := p.(Cache); ok {
|
||||
registerCache(p.(Cache))
|
||||
}
|
||||
|
||||
if _, ok := p.(UserCenter); ok {
|
||||
registerUserCenter(p.(UserCenter))
|
||||
}
|
||||
|
||||
if _, ok := p.(Agent); ok {
|
||||
registerAgent(p.(Agent))
|
||||
}
|
||||
|
||||
if _, ok := p.(Search); ok {
|
||||
registerSearch(p.(Search))
|
||||
}
|
||||
|
||||
if _, ok := p.(Notification); ok {
|
||||
registerNotification(p.(Notification))
|
||||
}
|
||||
|
||||
if _, ok := p.(Reviewer); ok {
|
||||
registerReviewer(p.(Reviewer))
|
||||
}
|
||||
|
||||
if _, ok := p.(Captcha); ok {
|
||||
registerCaptcha(p.(Captcha))
|
||||
}
|
||||
|
||||
if _, ok := p.(Embed); ok {
|
||||
registerEmbed(p.(Embed))
|
||||
}
|
||||
|
||||
if _, ok := p.(Render); ok {
|
||||
registerRender(p.(Render))
|
||||
}
|
||||
|
||||
if _, ok := p.(CDN); ok {
|
||||
registerCDN(p.(CDN))
|
||||
}
|
||||
|
||||
if _, ok := p.(Importer); ok {
|
||||
registerImporter(p.(Importer))
|
||||
}
|
||||
|
||||
if _, ok := p.(KVStorage); ok {
|
||||
registerKVStorage(p.(KVStorage))
|
||||
}
|
||||
|
||||
if _, ok := p.(Sidebar); ok {
|
||||
registerSidebar(p.(Sidebar))
|
||||
}
|
||||
|
||||
if _, ok := p.(VectorSearch); ok {
|
||||
registerVectorSearch(p.(VectorSearch))
|
||||
}
|
||||
}
|
||||
|
||||
type Stack[T Base] struct {
|
||||
plugins []T
|
||||
}
|
||||
|
||||
type RegisterFn[T Base] func(p T)
|
||||
type Caller[T Base] func(p T) error
|
||||
type CallFn[T Base] func(fn Caller[T]) error
|
||||
|
||||
// MakePlugin creates a plugin caller and register stack manager
|
||||
// The parameter super presents if the plugin can be disabled.
|
||||
// It returns a register function and a caller function
|
||||
// The register function is used to register a plugin, it will be called in the plugin's init function
|
||||
// The caller function is used to call all registered plugins
|
||||
func MakePlugin[T Base](super bool) (CallFn[T], RegisterFn[T]) {
|
||||
stack := Stack[T]{}
|
||||
|
||||
call := func(fn Caller[T]) error {
|
||||
for _, p := range stack.plugins {
|
||||
// If the plugin is disabled, skip it
|
||||
if !super && !StatusManager.IsEnabled(p.Info().SlugName) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := fn(p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
register := func(p T) {
|
||||
for _, plugin := range stack.plugins {
|
||||
if plugin.Info().SlugName == p.Info().SlugName {
|
||||
panic("plugin " + p.Info().SlugName + " is already registered")
|
||||
}
|
||||
}
|
||||
stack.plugins = append(stack.plugins, p)
|
||||
}
|
||||
|
||||
return call, register
|
||||
}
|
||||
|
||||
type statusManager struct {
|
||||
lock sync.Mutex
|
||||
status map[string]bool
|
||||
}
|
||||
|
||||
func (m *statusManager) Enable(name string, enabled bool) {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
if !enabled {
|
||||
m.status[name] = enabled
|
||||
return
|
||||
}
|
||||
m.status[name] = enabled
|
||||
|
||||
for _, slugName := range coordinatedCaptchaPlugins(name) {
|
||||
m.status[slugName] = false
|
||||
}
|
||||
|
||||
for _, slugName := range coordinatedCDNPlugins(name) {
|
||||
m.status[slugName] = false
|
||||
}
|
||||
}
|
||||
|
||||
func (m *statusManager) IsEnabled(name string) bool {
|
||||
if status, ok := m.status[name]; ok {
|
||||
return status
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MarshalJSON implements the json.Marshaler interface.
|
||||
func (m *statusManager) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(m.status)
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements the json.Unmarshaler interface.
|
||||
func (m *statusManager) UnmarshalJSON(data []byte) error {
|
||||
return json.Unmarshal(data, &m.status)
|
||||
}
|
||||
|
||||
// Translate translates the key to the current language of the context
|
||||
func Translate(ctx *GinContext, key string) string {
|
||||
return translator.Tr(handler.GetLangByCtx(ctx), key)
|
||||
}
|
||||
|
||||
// TranslateWithData translates the key to the language with data
|
||||
func TranslateWithData(lang i18n.Language, key string, data any) string {
|
||||
return translator.TrWithData(lang, key, data)
|
||||
}
|
||||
|
||||
// TranslateFn presents a generator of translated string.
|
||||
// We use it to delegate the translation work outside the plugin.
|
||||
type TranslateFn func(ctx *GinContext) string
|
||||
|
||||
// Translator contains a function that translates the key to the current language of the context
|
||||
type Translator struct {
|
||||
Fn TranslateFn
|
||||
}
|
||||
|
||||
// MakeTranslator generates a translator from the key
|
||||
func MakeTranslator(key string) Translator {
|
||||
t := func(ctx *GinContext) string {
|
||||
return Translate(ctx, key)
|
||||
}
|
||||
return Translator{Fn: t}
|
||||
}
|
||||
|
||||
// Translate translates the key to the current language of the context
|
||||
func (t Translator) Translate(ctx *GinContext) string {
|
||||
if t.Fn == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Fn(ctx)
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/answer/plugin"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
testPlugin *TestKVStoragePlugin
|
||||
)
|
||||
|
||||
// Helper functions for testing
|
||||
func mustSet(t *testing.T, kv *plugin.KVOperator, ctx context.Context, group, key, value string) {
|
||||
if err := kv.Set(ctx, plugin.KVParams{Group: group, Key: key, Value: value}); err != nil {
|
||||
t.Fatalf("Failed to set %s/%s: %v", group, key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustGet(t *testing.T, kv *plugin.KVOperator, ctx context.Context, group, key, expected string) {
|
||||
val, err := kv.Get(ctx, plugin.KVParams{Group: group, Key: key})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get %s/%s: %v", group, key, err)
|
||||
}
|
||||
if val != expected {
|
||||
t.Errorf("Expected '%s' for %s/%s, got '%s'", expected, group, key, val)
|
||||
}
|
||||
}
|
||||
|
||||
func mustDel(t *testing.T, kv *plugin.KVOperator, ctx context.Context, group, key string) {
|
||||
if err := kv.Del(ctx, plugin.KVParams{Group: group, Key: key}); err != nil {
|
||||
t.Fatalf("Failed to delete %s/%s: %v", group, key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNotFound(t *testing.T, kv *plugin.KVOperator, ctx context.Context, group, key string) {
|
||||
val, err := kv.Get(ctx, plugin.KVParams{Group: group, Key: key})
|
||||
if err != plugin.ErrKVKeyNotFound {
|
||||
t.Errorf("Expected ErrKVKeyNotFound for %s/%s, got: %v", group, key, err)
|
||||
}
|
||||
if val != "" {
|
||||
t.Errorf("Expected empty value for %s/%s, got: '%s'", group, key, val)
|
||||
}
|
||||
}
|
||||
|
||||
func assertError(t *testing.T, err error, expected error, msg string) {
|
||||
if err != expected {
|
||||
t.Errorf("%s: expected %v, got %v", msg, expected, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKVStoragePlugin implements KVStorage interface for testing
|
||||
type TestKVStoragePlugin struct {
|
||||
operator *plugin.KVOperator
|
||||
}
|
||||
|
||||
// Info returns plugin information
|
||||
func (p *TestKVStoragePlugin) Info() plugin.Info {
|
||||
return plugin.Info{
|
||||
Name: plugin.MakeTranslator("test_kv_storage_name"),
|
||||
SlugName: "test_kv_storage",
|
||||
Description: plugin.MakeTranslator("test_kv_storage_desc"),
|
||||
Author: "Answer Team",
|
||||
Version: "1.0.0",
|
||||
Link: "https://github.com/apache/answer",
|
||||
}
|
||||
}
|
||||
|
||||
// SetOperator sets KV operator
|
||||
func (p *TestKVStoragePlugin) SetOperator(operator *plugin.KVOperator) {
|
||||
p.operator = operator
|
||||
}
|
||||
|
||||
// setupTestEnvironment sets up test environment
|
||||
func setupTestEnvironment() {
|
||||
// Initialize only once
|
||||
if testPlugin != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Create and register test plugin
|
||||
testPlugin = &TestKVStoragePlugin{}
|
||||
plugin.Register(testPlugin)
|
||||
|
||||
// Enable plugin
|
||||
plugin.StatusManager.Enable("test_kv_storage", true)
|
||||
|
||||
// Initialize plugin data, refer to plugin_common_service.go implementation
|
||||
_ = plugin.CallKVStorage(func(k plugin.KVStorage) error {
|
||||
k.SetOperator(plugin.NewKVOperator(
|
||||
testDataSource.DB,
|
||||
testDataSource.Cache,
|
||||
k.Info().SlugName,
|
||||
))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Test basic operations including CRUD and edge cases
|
||||
func TestBasicOperations(t *testing.T) {
|
||||
setupTestEnvironment()
|
||||
kv := testPlugin.operator
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("BasicCRUD", func(t *testing.T) {
|
||||
// Set/Get
|
||||
mustSet(t, kv, ctx, "group1", "key1", "value1")
|
||||
mustGet(t, kv, ctx, "group1", "key1", "value1")
|
||||
|
||||
// Update
|
||||
mustSet(t, kv, ctx, "group1", "key1", "new_value")
|
||||
mustGet(t, kv, ctx, "group1", "key1", "new_value")
|
||||
|
||||
// Delete
|
||||
mustDel(t, kv, ctx, "group1", "key1")
|
||||
assertNotFound(t, kv, ctx, "group1", "key1")
|
||||
|
||||
// Group operation
|
||||
mustSet(t, kv, ctx, "group1", "key2", "value2")
|
||||
mustSet(t, kv, ctx, "group1", "key3", "value3")
|
||||
groupData, err := kv.GetByGroup(ctx, plugin.KVParams{Group: "group1", Page: 1, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get group data: %v", err)
|
||||
}
|
||||
|
||||
// the groupData should only have key2 and key3 because key1 is deleted
|
||||
if len(groupData) != 2 {
|
||||
t.Errorf("Expected 2 items, got %d", len(groupData))
|
||||
}
|
||||
if groupData["key2"] != "value2" || groupData["key3"] != "value3" {
|
||||
t.Errorf("Unexpected group data: %v", groupData)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EdgeCases", func(t *testing.T) {
|
||||
// Empty key
|
||||
err := kv.Set(ctx, plugin.KVParams{Group: "group", Key: "", Value: "value"})
|
||||
assertError(t, err, plugin.ErrKVKeyEmpty, "Empty key test")
|
||||
|
||||
// Empty group query
|
||||
_, err = kv.GetByGroup(ctx, plugin.KVParams{Group: "", Page: 1, PageSize: 10})
|
||||
assertError(t, err, plugin.ErrKVGroupEmpty, "Empty group test")
|
||||
|
||||
// Non-existent key
|
||||
assertNotFound(t, kv, ctx, "non_exist_group", "non_exist_key")
|
||||
|
||||
// Cache penetration protection
|
||||
key := fmt.Sprintf("non_exist_key_%d", time.Now().UnixNano())
|
||||
assertNotFound(t, kv, ctx, "cache_penetration", key)
|
||||
})
|
||||
|
||||
t.Run("CacheConsistency", func(t *testing.T) {
|
||||
mustSet(t, kv, ctx, "cache_group", "cache_key", "cache_value")
|
||||
mustGet(t, kv, ctx, "cache_group", "cache_key", "cache_value")
|
||||
|
||||
// Update and verify immediate consistency
|
||||
mustSet(t, kv, ctx, "cache_group", "cache_key", "updated_value")
|
||||
mustGet(t, kv, ctx, "cache_group", "cache_key", "updated_value")
|
||||
})
|
||||
}
|
||||
|
||||
// Test transactions including rollback and nested transactions
|
||||
func TestTransactions(t *testing.T) {
|
||||
setupTestEnvironment()
|
||||
kv := testPlugin.operator
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("SuccessfulTransaction", func(t *testing.T) {
|
||||
err := kv.Tx(ctx, func(ctx context.Context, txKv *plugin.KVOperator) error {
|
||||
if err := txKv.Set(ctx, plugin.KVParams{Group: "tx_group", Key: "tx_key1", Value: "tx_value1"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := txKv.Set(ctx, plugin.KVParams{Group: "tx_group", Key: "tx_key2", Value: "tx_value2"}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Successful transaction failed: %v", err)
|
||||
}
|
||||
|
||||
mustGet(t, kv, ctx, "tx_group", "tx_key1", "tx_value1")
|
||||
mustGet(t, kv, ctx, "tx_group", "tx_key2", "tx_value2")
|
||||
})
|
||||
|
||||
t.Run("TransactionRollback", func(t *testing.T) {
|
||||
err := kv.Tx(ctx, func(ctx context.Context, txKv *plugin.KVOperator) error {
|
||||
if err := txKv.Set(ctx, plugin.KVParams{Group: "tx_group", Key: "tx_key3", Value: "tx_value3"}); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("mock error")
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("Expected transaction to fail but it succeeded")
|
||||
}
|
||||
|
||||
assertNotFound(t, kv, ctx, "tx_group", "tx_key3")
|
||||
})
|
||||
|
||||
t.Run("NestedTransactions", func(t *testing.T) {
|
||||
err := kv.Tx(ctx, func(ctx context.Context, txKv *plugin.KVOperator) error {
|
||||
if err := txKv.Set(ctx, plugin.KVParams{Group: "nested", Key: "key1", Value: "value1"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return txKv.Tx(ctx, func(ctx context.Context, nestedKv *plugin.KVOperator) error {
|
||||
if err := nestedKv.Set(ctx, plugin.KVParams{Group: "nested", Key: "key2", Value: "value2"}); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("mock nested error")
|
||||
})
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("Expected nested transaction to fail but it succeeded")
|
||||
}
|
||||
|
||||
// Verify outer transaction also rolled back
|
||||
assertNotFound(t, kv, ctx, "nested", "key1")
|
||||
assertNotFound(t, kv, ctx, "nested", "key2")
|
||||
})
|
||||
}
|
||||
|
||||
// Test pagination in GetByGroup
|
||||
func TestPagination(t *testing.T) {
|
||||
setupTestEnvironment()
|
||||
kv := testPlugin.operator
|
||||
ctx := context.Background()
|
||||
totalItems := 25
|
||||
|
||||
for i := range totalItems {
|
||||
mustSet(t, kv, ctx, "pagination", fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i))
|
||||
}
|
||||
|
||||
// Test pagination
|
||||
page1, err := kv.GetByGroup(ctx, plugin.KVParams{Group: "pagination", Page: 1, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get page 1: %v", err)
|
||||
}
|
||||
if len(page1) != 10 {
|
||||
t.Errorf("Page 1: expected 10 items, got %d", len(page1))
|
||||
}
|
||||
|
||||
page2, err := kv.GetByGroup(ctx, plugin.KVParams{Group: "pagination", Page: 2, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get page 2: %v", err)
|
||||
}
|
||||
if len(page2) != 10 {
|
||||
t.Errorf("Page 2: expected 10 items, got %d", len(page2))
|
||||
}
|
||||
|
||||
page3, err := kv.GetByGroup(ctx, plugin.KVParams{Group: "pagination", Page: 3, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get page 3: %v", err)
|
||||
}
|
||||
if len(page3) != 5 {
|
||||
t.Errorf("Page 3: expected 5 items, got %d", len(page3))
|
||||
}
|
||||
|
||||
// Verify different keys on different pages
|
||||
for i := range 10 {
|
||||
key := fmt.Sprintf("key%d", i)
|
||||
if _, ok := page1[key]; !ok {
|
||||
t.Errorf("Pagination test failed, key %s should be on page 1", key)
|
||||
}
|
||||
}
|
||||
for i := range 10 {
|
||||
key := fmt.Sprintf("key%d", i+10)
|
||||
if _, ok := page2[key]; !ok {
|
||||
t.Errorf("Pagination test failed, key %s should be on page 2", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test concurrent operations and performance
|
||||
func TestConcurrency(t *testing.T) {
|
||||
setupTestEnvironment()
|
||||
kv := testPlugin.operator
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("BasicConcurrency", func(t *testing.T) {
|
||||
parallel := 10
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(parallel)
|
||||
|
||||
for i := range parallel {
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond)
|
||||
mustSet(t, kv, ctx, "concurrent", fmt.Sprintf("key%d", index), "value")
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Verify results
|
||||
wg.Add(parallel)
|
||||
for i := range parallel {
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond)
|
||||
mustGet(t, kv, ctx, "concurrent", fmt.Sprintf("key%d", index), "value")
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("StressTest", func(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping stress test in short mode")
|
||||
}
|
||||
|
||||
totalOps := 1000
|
||||
workerCount := 20
|
||||
prefix := "stress_test"
|
||||
opsPerWorker := totalOps / workerCount
|
||||
|
||||
log.Info("Starting KV storage stress test...")
|
||||
startTime := time.Now()
|
||||
|
||||
// Concurrent write test
|
||||
var wg sync.WaitGroup
|
||||
errorCount := int64(0)
|
||||
|
||||
for w := range workerCount {
|
||||
wg.Add(1)
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
startIdx := workerID * opsPerWorker
|
||||
|
||||
for i := range opsPerWorker {
|
||||
i := startIdx + i
|
||||
err := kv.Set(ctx, plugin.KVParams{
|
||||
Group: prefix,
|
||||
Key: fmt.Sprintf("key%d", i),
|
||||
Value: fmt.Sprintf("value%d", i),
|
||||
})
|
||||
if err != nil {
|
||||
log.Warnf("Write error: %v", err)
|
||||
errorCount++
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
writeTime := time.Since(startTime)
|
||||
|
||||
// Verify data integrity
|
||||
groupData, err := kv.GetByGroup(ctx, plugin.KVParams{Group: prefix, Page: 1, PageSize: totalOps})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to verify data: %v", err)
|
||||
}
|
||||
if len(groupData) != totalOps {
|
||||
t.Errorf("Data loss: expected %d items, got %d", totalOps, len(groupData))
|
||||
}
|
||||
|
||||
// Concurrent read test
|
||||
startTime = time.Now()
|
||||
readErrors := int64(0)
|
||||
|
||||
wg.Add(workerCount)
|
||||
for range workerCount {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range opsPerWorker {
|
||||
keyIdx := rand.Intn(totalOps)
|
||||
key := fmt.Sprintf("key%d", keyIdx)
|
||||
expected := fmt.Sprintf("value%d", keyIdx)
|
||||
|
||||
val, err := kv.Get(ctx, plugin.KVParams{Group: prefix, Key: key})
|
||||
if err != nil {
|
||||
readErrors++
|
||||
} else if val != expected {
|
||||
t.Errorf("Data inconsistency: key=%s, expected=%s, got=%s", key, expected, val)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
readTime := time.Since(startTime)
|
||||
|
||||
log.Infof("Stress test completed:")
|
||||
log.Infof(" Write: %d ops in %v (%.1f ops/sec), %d errors", totalOps, writeTime, float64(totalOps)/writeTime.Seconds(), errorCount)
|
||||
log.Infof(" Read: %d ops in %v (%.1f ops/sec), %d errors", totalOps, readTime, float64(totalOps)/readTime.Seconds(), readErrors)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/answer/internal/base/data"
|
||||
"github.com/apache/answer/internal/migrations"
|
||||
"github.com/ory/dockertest/v3"
|
||||
"github.com/ory/dockertest/v3/docker"
|
||||
"github.com/segmentfault/pacman/cache"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
"xorm.io/xorm"
|
||||
"xorm.io/xorm/schemas"
|
||||
)
|
||||
|
||||
var (
|
||||
mysqlDBSetting = TestDBSetting{
|
||||
Driver: string(schemas.MYSQL),
|
||||
ImageName: "mariadb",
|
||||
ImageVersion: "10.4.7",
|
||||
ENV: []string{"MYSQL_ROOT_PASSWORD=root", "MYSQL_DATABASE=answer", "MYSQL_ROOT_HOST=%"},
|
||||
PortID: "3306/tcp",
|
||||
Connection: "root:root@(localhost:%s)/answer?parseTime=true", // port is not fixed, it will be got by port id
|
||||
}
|
||||
postgresDBSetting = TestDBSetting{
|
||||
Driver: string(schemas.POSTGRES),
|
||||
ImageName: "postgres",
|
||||
ImageVersion: "14",
|
||||
ENV: []string{"POSTGRES_USER=root", "POSTGRES_PASSWORD=root", "POSTGRES_DB=answer", "LISTEN_ADDRESSES='*'"},
|
||||
PortID: "5432/tcp",
|
||||
Connection: "host=localhost port=%s user=root password=root dbname=answer sslmode=disable",
|
||||
}
|
||||
sqlite3DBSetting = TestDBSetting{
|
||||
Driver: string(schemas.SQLITE),
|
||||
Connection: filepath.Join(os.TempDir(), "answer-test-data.db"),
|
||||
}
|
||||
dbSettingMapping = map[string]TestDBSetting{
|
||||
mysqlDBSetting.Driver: mysqlDBSetting,
|
||||
sqlite3DBSetting.Driver: sqlite3DBSetting,
|
||||
postgresDBSetting.Driver: postgresDBSetting,
|
||||
}
|
||||
// after all test down will execute tearDown function to clean-up
|
||||
tearDown func()
|
||||
// testDataSource used for repo testing
|
||||
testDataSource *data.Data
|
||||
testCache cache.Cache
|
||||
)
|
||||
|
||||
func TestMain(t *testing.M) {
|
||||
dbSetting, ok := dbSettingMapping[os.Getenv("TEST_DB_DRIVER")]
|
||||
if !ok {
|
||||
// Use sqlite3 to test.
|
||||
dbSetting = dbSettingMapping[string(schemas.SQLITE)]
|
||||
}
|
||||
if dbSetting.Driver == string(schemas.SQLITE) {
|
||||
_ = os.RemoveAll(dbSetting.Connection)
|
||||
}
|
||||
|
||||
if err := initTestDataSource(dbSetting); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
log.Info("init test database successfully")
|
||||
|
||||
ret := t.Run()
|
||||
if tearDown != nil {
|
||||
tearDown()
|
||||
}
|
||||
os.Exit(ret)
|
||||
}
|
||||
|
||||
type TestDBSetting struct {
|
||||
Driver string
|
||||
ImageName string
|
||||
ImageVersion string
|
||||
ENV []string
|
||||
PortID string
|
||||
Connection string
|
||||
}
|
||||
|
||||
func initTestDataSource(dbSetting TestDBSetting) error {
|
||||
connection, imageCleanUp, err := initDatabaseImage(dbSetting)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dbSetting.Connection = connection
|
||||
|
||||
dbEngine, err := initDatabase(dbSetting)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newCache, err := initCache()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newData, dbCleanUp, err := data.NewData(dbEngine, newCache)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
testDataSource = newData
|
||||
testCache = newCache
|
||||
|
||||
tearDown = func() {
|
||||
dbCleanUp()
|
||||
log.Info("cleanup test database successfully")
|
||||
imageCleanUp()
|
||||
log.Info("cleanup test database image successfully")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initDatabaseImage(dbSetting TestDBSetting) (connection string, cleanup func(), err error) {
|
||||
// sqlite3 don't need to set up image
|
||||
if dbSetting.Driver == string(schemas.SQLITE) {
|
||||
return dbSetting.Connection, func() {
|
||||
log.Info("remove database", dbSetting.Connection)
|
||||
err = os.Remove(dbSetting.Connection)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
pool, err := dockertest.NewPool("")
|
||||
pool.MaxWait = time.Minute * 5
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("could not connect to docker: %s", err)
|
||||
}
|
||||
|
||||
// resource, err := pool.Run(dbSetting.ImageName, dbSetting.ImageVersion, dbSetting.ENV)
|
||||
resource, err := pool.RunWithOptions(&dockertest.RunOptions{
|
||||
Repository: dbSetting.ImageName,
|
||||
Tag: dbSetting.ImageVersion,
|
||||
Env: dbSetting.ENV,
|
||||
}, func(config *docker.HostConfig) {
|
||||
config.AutoRemove = true
|
||||
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("could not pull resource: %s", err)
|
||||
}
|
||||
|
||||
connection = fmt.Sprintf(dbSetting.Connection, resource.GetPort(dbSetting.PortID))
|
||||
if err := pool.Retry(func() error {
|
||||
db, err := sql.Open(dbSetting.Driver, connection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Ping()
|
||||
}); err != nil {
|
||||
return "", nil, fmt.Errorf("could not connect to database: %s", err)
|
||||
}
|
||||
return connection, func() { _ = pool.Purge(resource) }, nil
|
||||
}
|
||||
|
||||
func initDatabase(dbSetting TestDBSetting) (dbEngine *xorm.Engine, err error) {
|
||||
dataConf := &data.Database{Driver: dbSetting.Driver, Connection: dbSetting.Connection}
|
||||
dbEngine, err = data.NewDB(true, dataConf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connection to database failed: %s", err)
|
||||
}
|
||||
if err := migrations.NewMentor(context.TODO(), dbEngine, &migrations.InitNeedUserInputData{
|
||||
Language: "en_US",
|
||||
SiteName: "ANSWER",
|
||||
SiteURL: "http://127.0.0.1:8080/",
|
||||
ContactEmail: "answer@answer.com",
|
||||
AdminName: "admin",
|
||||
AdminPassword: "admin",
|
||||
AdminEmail: "answer@answer.com",
|
||||
}).InitDB(); err != nil {
|
||||
return nil, fmt.Errorf("migrations init database failed: %s", err)
|
||||
}
|
||||
return dbEngine, nil
|
||||
}
|
||||
|
||||
func initCache() (newCache cache.Cache, err error) {
|
||||
newCache, _, err = data.NewCache(&data.CacheConf{})
|
||||
return newCache, err
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
type RenderConfig struct {
|
||||
SelectTheme string `json:"select_theme"`
|
||||
}
|
||||
|
||||
// select_theme
|
||||
|
||||
type Render interface {
|
||||
Base
|
||||
GetRenderConfig(ctx *gin.Context) (renderConfig *RenderConfig)
|
||||
}
|
||||
|
||||
var (
|
||||
// CallRender is a function that calls all registered parsers
|
||||
CallRender,
|
||||
registerRender = MakePlugin[Render](false)
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
type Reviewer interface {
|
||||
Base
|
||||
Review(content *ReviewContent) (result *ReviewResult)
|
||||
}
|
||||
|
||||
// ReviewContent is a struct that contains the content of a review
|
||||
type ReviewContent struct {
|
||||
// The type of the content, e.g. question, answer
|
||||
ObjectType string
|
||||
// The title of the content, only available for the question
|
||||
Title string
|
||||
// The content of the review, always available
|
||||
Content string
|
||||
// The tags of the content, only available for the question
|
||||
Tags []string
|
||||
// The author of the content
|
||||
Author ReviewContentAuthor
|
||||
// Review Language, the site language. e.g. en_US
|
||||
// The plugin may reply the review result according to the language
|
||||
Language string
|
||||
// The user agent of the request web browser
|
||||
UserAgent string
|
||||
// The IP address of the request
|
||||
IP string
|
||||
}
|
||||
|
||||
type ReviewContentAuthor struct {
|
||||
// The user's reputation
|
||||
Rank int
|
||||
// The amount of questions that has approved
|
||||
ApprovedQuestionAmount int64
|
||||
// The amount of answers that has approved
|
||||
ApprovedAnswerAmount int64
|
||||
// 1:User 2:Admin 3:Moderator
|
||||
Role int
|
||||
}
|
||||
|
||||
type ReviewStatus string
|
||||
|
||||
const (
|
||||
ReviewStatusApproved ReviewStatus = "approved"
|
||||
ReviewStatusDeleteDirectly ReviewStatus = "delete_directly"
|
||||
ReviewStatusNeedReview ReviewStatus = "need_review"
|
||||
)
|
||||
|
||||
// ReviewResult is a struct that contains the result of a review
|
||||
type ReviewResult struct {
|
||||
// If the review is approved
|
||||
Approved bool
|
||||
// The status of the review
|
||||
ReviewStatus ReviewStatus
|
||||
// The reason for the result
|
||||
Reason string
|
||||
}
|
||||
|
||||
var (
|
||||
// CallReviewer is a function that calls all registered parsers
|
||||
CallReviewer,
|
||||
registerReviewer = MakePlugin[Reviewer](false)
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type SearchResult struct {
|
||||
// ID content ID
|
||||
ID string
|
||||
// Type content type, example: "answer", "question"
|
||||
Type string
|
||||
}
|
||||
|
||||
type SearchContent struct {
|
||||
ObjectID string `json:"objectID"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Answers int64 `json:"answers"`
|
||||
Status SearchContentStatus `json:"status"`
|
||||
Tags []string `json:"tags"`
|
||||
QuestionID string `json:"questionID"`
|
||||
UserID string `json:"userID"`
|
||||
Views int64 `json:"views"`
|
||||
Created int64 `json:"created"`
|
||||
Active int64 `json:"active"`
|
||||
Score int64 `json:"score"`
|
||||
HasAccepted bool `json:"hasAccepted"`
|
||||
}
|
||||
|
||||
type SearchBasicCond struct {
|
||||
// From zero-based page number
|
||||
Page int
|
||||
// Page size
|
||||
PageSize int
|
||||
|
||||
// The keywords for search.
|
||||
Words []string
|
||||
// TagIDs is a list of tag IDs.
|
||||
TagIDs [][]string
|
||||
// The object's owner user ID.
|
||||
UserID string
|
||||
// The order of the search result.
|
||||
Order SearchOrderCond
|
||||
|
||||
// Weathers the question is accepted or not. Only support search question.
|
||||
QuestionAccepted SearchAcceptedCond
|
||||
// Weathers the answer is accepted or not. Only support search answer.
|
||||
AnswerAccepted SearchAcceptedCond
|
||||
|
||||
// Only support search answer.
|
||||
QuestionID string
|
||||
|
||||
// greater than or equal to the number of votes.
|
||||
VoteAmount int
|
||||
// greater than or equal to the number of views.
|
||||
ViewAmount int
|
||||
// greater than or equal to the number of answers. Only support search question.
|
||||
AnswerAmount int
|
||||
}
|
||||
|
||||
type SearchAcceptedCond int
|
||||
type SearchContentStatus int
|
||||
type SearchOrderCond string
|
||||
|
||||
const (
|
||||
AcceptedCondAll SearchAcceptedCond = iota
|
||||
AcceptedCondTrue
|
||||
AcceptedCondFalse
|
||||
)
|
||||
|
||||
const (
|
||||
SearchContentStatusAvailable = 1
|
||||
SearchContentStatusDeleted = 10
|
||||
)
|
||||
|
||||
const (
|
||||
SearchNewestOrder SearchOrderCond = "newest"
|
||||
SearchActiveOrder SearchOrderCond = "active"
|
||||
SearchScoreOrder SearchOrderCond = "score"
|
||||
SearchRelevanceOrder SearchOrderCond = "relevance"
|
||||
)
|
||||
|
||||
type Search interface {
|
||||
Base
|
||||
Description() SearchDesc
|
||||
RegisterSyncer(ctx context.Context, syncer SearchSyncer)
|
||||
SearchContents(ctx context.Context, cond *SearchBasicCond) (res []SearchResult, total int64, err error)
|
||||
SearchQuestions(ctx context.Context, cond *SearchBasicCond) (res []SearchResult, total int64, err error)
|
||||
SearchAnswers(ctx context.Context, cond *SearchBasicCond) (res []SearchResult, total int64, err error)
|
||||
UpdateContent(ctx context.Context, content *SearchContent) (err error)
|
||||
DeleteContent(ctx context.Context, objectID string) (err error)
|
||||
}
|
||||
|
||||
type SearchDesc struct {
|
||||
// A svg icon it wil be display in search result page. optional
|
||||
Icon string `json:"icon"`
|
||||
// The link address of the search engine. optional
|
||||
Link string `json:"link"`
|
||||
}
|
||||
|
||||
type SearchSyncer interface {
|
||||
GetAnswersPage(ctx context.Context, page, pageSize int) (answerList []*SearchContent, err error)
|
||||
GetQuestionsPage(ctx context.Context, page, pageSize int) (questionList []*SearchContent, err error)
|
||||
}
|
||||
|
||||
var (
|
||||
// CallSearch is a function that calls all registered parsers
|
||||
CallSearch,
|
||||
registerSearch = MakePlugin[Search](false)
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
// Package plugin
|
||||
package plugin
|
||||
|
||||
type SidebarConfig struct {
|
||||
Tags []*TagSelectorOption `json:"tags"`
|
||||
LinksText string `json:"links_text"`
|
||||
}
|
||||
|
||||
type Sidebar interface {
|
||||
Base
|
||||
GetSidebarConfig() (sidebarConfig *SidebarConfig, err error)
|
||||
}
|
||||
|
||||
var (
|
||||
// CallRender is a function that calls all registered parsers
|
||||
CallSidebar,
|
||||
registerSidebar = MakePlugin[Sidebar](false)
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
type UploadSource string
|
||||
|
||||
const (
|
||||
UserAvatar UploadSource = "user_avatar"
|
||||
UserPost UploadSource = "user_post"
|
||||
UserPostAttachment UploadSource = "user_post_attachment"
|
||||
AdminBranding UploadSource = "admin_branding"
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultFileTypeCheckMapping = map[UploadSource]map[string]bool{
|
||||
UserAvatar: {
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".png": true,
|
||||
".webp": true,
|
||||
},
|
||||
UserPost: {
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".png": true,
|
||||
".gif": true,
|
||||
".webp": true,
|
||||
},
|
||||
AdminBranding: {
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".png": true,
|
||||
".ico": true,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type UploadFileCondition struct {
|
||||
// Source is the source of the file
|
||||
Source UploadSource
|
||||
// MaxImageSize is the maximum size of the image in MB
|
||||
MaxImageSize int
|
||||
// MaxAttachmentSize is the maximum size of the attachment in MB
|
||||
MaxAttachmentSize int
|
||||
// MaxImageMegapixel is the maximum megapixel of the image
|
||||
MaxImageMegapixel int
|
||||
// AuthorizedImageExtensions is the list of authorized image extensions
|
||||
AuthorizedImageExtensions []string
|
||||
// AuthorizedAttachmentExtensions is the list of authorized attachment extensions
|
||||
AuthorizedAttachmentExtensions []string
|
||||
}
|
||||
|
||||
type UploadFileResponse struct {
|
||||
// FullURL is the URL that can be used to access the file
|
||||
FullURL string
|
||||
// OriginalError is the error returned by the storage plugin. It is used for debugging.
|
||||
OriginalError error
|
||||
// DisplayErrorMsg is the error message that will be displayed to the user.
|
||||
DisplayErrorMsg Translator
|
||||
}
|
||||
|
||||
type Storage interface {
|
||||
Base
|
||||
|
||||
// UploadFile uploads a file to storage.
|
||||
// The file is in the Form of the ctx and the key is "file"
|
||||
UploadFile(ctx *GinContext, condition UploadFileCondition) UploadFileResponse
|
||||
}
|
||||
|
||||
var (
|
||||
// CallStorage is a function that calls all registered storage
|
||||
CallStorage,
|
||||
registerStorage = MakePlugin[Storage](false)
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
type UserCenter interface {
|
||||
Base
|
||||
// Description returns the description of the user center, including the name, icon, url, etc.
|
||||
Description() UserCenterDesc
|
||||
// ControlCenterItems returns the items that will be displayed in the control center
|
||||
ControlCenterItems() []ControlCenter
|
||||
// LoginCallback is called when the user center login callback is called
|
||||
LoginCallback(ctx *GinContext) (userInfo *UserCenterBasicUserInfo, err error)
|
||||
// SignUpCallback is called when the user center sign up callback is called
|
||||
SignUpCallback(ctx *GinContext) (userInfo *UserCenterBasicUserInfo, err error)
|
||||
// UserInfo returns the user information
|
||||
UserInfo(externalID string) (userInfo *UserCenterBasicUserInfo, err error)
|
||||
// UserStatus returns the latest user status
|
||||
UserStatus(externalID string) (userStatus UserStatus)
|
||||
// UserList returns the user list information
|
||||
UserList(externalIDs []string) (userInfo []*UserCenterBasicUserInfo, err error)
|
||||
// UserSettings returns the user settings
|
||||
UserSettings(externalID string) (userSettings *SettingInfo, err error)
|
||||
// PersonalBranding returns the personal branding information
|
||||
PersonalBranding(externalID string) (branding []*PersonalBranding)
|
||||
// AfterLogin is called after the user logs in
|
||||
AfterLogin(externalID, accessToken string)
|
||||
}
|
||||
|
||||
type UserCenterDesc struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName Translator `json:"display_name"`
|
||||
Icon string `json:"icon"`
|
||||
Url string `json:"url"`
|
||||
LoginRedirectURL string `json:"login_redirect_url"`
|
||||
SignUpRedirectURL string `json:"sign_up_redirect_url"`
|
||||
RankAgentEnabled bool `json:"rank_agent_enabled"`
|
||||
UserStatusAgentEnabled bool `json:"user_status_agent_enabled"`
|
||||
UserRoleAgentEnabled bool `json:"user_role_agent_enabled"`
|
||||
MustAuthEmailEnabled bool `json:"must_auth_email_enabled"`
|
||||
EnabledOriginalUserSystem bool `json:"enabled_original_user_system"`
|
||||
}
|
||||
|
||||
type UserStatus int
|
||||
|
||||
const (
|
||||
UserStatusAvailable UserStatus = 1
|
||||
UserStatusSuspended UserStatus = 9
|
||||
UserStatusDeleted UserStatus = 10
|
||||
)
|
||||
|
||||
type UserCenterBasicUserInfo struct {
|
||||
ExternalID string `json:"external_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Rank int `json:"rank"`
|
||||
Avatar string `json:"avatar"`
|
||||
Mobile string `json:"mobile"`
|
||||
Bio string `json:"bio"`
|
||||
Status UserStatus `json:"status"`
|
||||
}
|
||||
|
||||
type ControlCenter struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
type SettingInfo struct {
|
||||
ProfileSettingRedirectURL string `json:"profile_setting_redirect_url"`
|
||||
AccountSettingRedirectURL string `json:"account_setting_redirect_url"`
|
||||
}
|
||||
|
||||
type PersonalBranding struct {
|
||||
Icon string `json:"icon"`
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
var (
|
||||
// CallUserCenter is a function that calls all registered parsers
|
||||
CallUserCenter,
|
||||
registerUserCenter = MakePlugin[UserCenter](false)
|
||||
)
|
||||
|
||||
func UserCenterEnabled() (enabled bool) {
|
||||
_ = CallUserCenter(func(fn UserCenter) error {
|
||||
enabled = true
|
||||
return nil
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func RankAgentEnabled() (enabled bool) {
|
||||
_ = CallUserCenter(func(fn UserCenter) error {
|
||||
enabled = fn.Description().RankAgentEnabled
|
||||
return nil
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func GetUserCenter() (uc UserCenter, ok bool) {
|
||||
_ = CallUserCenter(func(fn UserCenter) error {
|
||||
uc = fn
|
||||
ok = true
|
||||
return nil
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
type UserConfig interface {
|
||||
Base
|
||||
|
||||
// UserConfigFields returns the list of config fields
|
||||
UserConfigFields() []ConfigField
|
||||
// UserConfigReceiver receives the config data, it calls when the config is saved or initialized.
|
||||
// We recommend to unmarshal the data to a struct, and then use the struct to do something.
|
||||
// The config is encoded in JSON format.
|
||||
// It depends on the definition of ConfigFields.
|
||||
UserConfigReceiver(userID string, config []byte) error
|
||||
}
|
||||
|
||||
var (
|
||||
// CallUserConfig is a function that calls all registered config plugins
|
||||
CallUserConfig,
|
||||
registerUserConfig = MakePlugin[UserConfig](false)
|
||||
getPluginUserConfigFn func(userID, pluginSlugName string) []byte
|
||||
)
|
||||
|
||||
// GetPluginUserConfig returns the user config of the given user id
|
||||
func GetPluginUserConfig(userID, pluginSlugName string) []byte {
|
||||
if getPluginUserConfigFn != nil {
|
||||
return getPluginUserConfigFn(userID, pluginSlugName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterGetPluginUserConfigFunc registers a function to get the user config of the given user id
|
||||
func RegisterGetPluginUserConfigFunc(fn func(userID, pluginSlugName string) []byte) {
|
||||
getPluginUserConfigFn = fn
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
)
|
||||
|
||||
// VectorSearchResult holds a single similarity search result returned by a VectorSearch plugin.
|
||||
type VectorSearchResult struct {
|
||||
// ObjectID is the unique identifier of the matched object (question ID or answer ID).
|
||||
ObjectID string `json:"object_id"`
|
||||
// ObjectType is "question" or "answer".
|
||||
ObjectType string `json:"object_type"`
|
||||
// Metadata is a JSON string containing VectorSearchMetadata for link composition and content retrieval.
|
||||
Metadata string `json:"metadata"`
|
||||
// Score is the cosine similarity score (0-1).
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// VectorSearchContent is the document structure passed to plugins for indexing.
|
||||
type VectorSearchContent struct {
|
||||
// ObjectID is the unique identifier (question ID or answer ID).
|
||||
ObjectID string `json:"objectID"`
|
||||
// ObjectType is "question" or "answer".
|
||||
ObjectType string `json:"objectType"`
|
||||
// Title is the question title.
|
||||
Title string `json:"title"`
|
||||
// Content is the aggregated text to be embedded (question body + answers + comments).
|
||||
Content string `json:"content"`
|
||||
// Metadata is a JSON string containing VectorSearchMetadata.
|
||||
Metadata string `json:"metadata"`
|
||||
}
|
||||
|
||||
// VectorSearchDesc describes the vector search engine for display purposes.
|
||||
type VectorSearchDesc struct {
|
||||
// Icon is an SVG icon for display. Optional.
|
||||
Icon string `json:"icon"`
|
||||
// Link is the URL of the vector search engine. Optional.
|
||||
Link string `json:"link"`
|
||||
}
|
||||
|
||||
// VectorSearchMetadata holds IDs for URI composition and content retrieval at query time.
|
||||
// Shared between plugins and the core MCP controller.
|
||||
type VectorSearchMetadata struct {
|
||||
QuestionID string `json:"question_id"`
|
||||
AnswerID string `json:"answer_id,omitempty"`
|
||||
Answers []VectorSearchMetadataAnswer `json:"answers,omitempty"`
|
||||
Comments []VectorSearchMetadataComment `json:"comments,omitempty"`
|
||||
}
|
||||
|
||||
// VectorSearchMetadataAnswer stores answer ID and its comment IDs in metadata.
|
||||
type VectorSearchMetadataAnswer struct {
|
||||
AnswerID string `json:"answer_id"`
|
||||
Comments []VectorSearchMetadataComment `json:"comments,omitempty"`
|
||||
}
|
||||
|
||||
// VectorSearchMetadataComment stores a comment ID in metadata.
|
||||
type VectorSearchMetadataComment struct {
|
||||
CommentID string `json:"comment_id"`
|
||||
}
|
||||
|
||||
// VectorSearch is the plugin interface for vector/semantic search engines.
|
||||
// Plugins implementing this interface manage their own vector storage, embedding computation,
|
||||
// data synchronization schedule, and similarity search.
|
||||
type VectorSearch interface {
|
||||
Base
|
||||
|
||||
// Description returns metadata about the vector search engine.
|
||||
Description() VectorSearchDesc
|
||||
|
||||
// RegisterSyncer is called by the core to provide a data syncer.
|
||||
// The plugin should store the syncer and use it to bulk-sync content
|
||||
// (typically in a background goroutine).
|
||||
RegisterSyncer(ctx context.Context, syncer VectorSearchSyncer)
|
||||
|
||||
// SearchSimilar performs a semantic similarity search.
|
||||
// The plugin is responsible for embedding the query text and searching its vector store.
|
||||
// Returns up to topK results sorted by similarity score (descending).
|
||||
SearchSimilar(ctx context.Context, query string, topK int) ([]VectorSearchResult, error)
|
||||
|
||||
// UpdateContent upserts a single document in the vector store.
|
||||
// Called by the core on incremental content changes.
|
||||
UpdateContent(ctx context.Context, content *VectorSearchContent) error
|
||||
|
||||
// DeleteContent removes a document from the vector store by object ID.
|
||||
DeleteContent(ctx context.Context, objectID string) error
|
||||
}
|
||||
|
||||
// VectorSearchSyncer is implemented by the core and provided to plugins via RegisterSyncer.
|
||||
// Plugins call these methods to pull all content for bulk indexing.
|
||||
type VectorSearchSyncer interface {
|
||||
// GetQuestionsPage returns a page of questions with aggregated text (title + body + answers + comments).
|
||||
GetQuestionsPage(ctx context.Context, page, pageSize int) ([]*VectorSearchContent, error)
|
||||
// GetAnswersPage returns a page of answers with aggregated text (answer body + parent question title + comments).
|
||||
GetAnswersPage(ctx context.Context, page, pageSize int) ([]*VectorSearchContent, error)
|
||||
}
|
||||
|
||||
var (
|
||||
// CallVectorSearch is a function that calls all registered VectorSearch plugins.
|
||||
CallVectorSearch,
|
||||
registerVectorSearch = MakePlugin[VectorSearch](false)
|
||||
)
|
||||
|
||||
// GenerateEmbedding is a base utility function that generates an embedding vector
|
||||
// using an OpenAI-compatible API. Plugins that don't have a built-in vectorizer
|
||||
// (most vector databases) can call this function with their own credentials.
|
||||
// Plugins with built-in vectorizers (e.g., Weaviate) can skip this and use their own.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: context for cancellation
|
||||
// - apiHost: the API base URL (e.g. "https://api.openai.com"); "/v1" is appended if missing
|
||||
// - apiKey: the API key for authentication
|
||||
// - model: the embedding model name (e.g. "text-embedding-3-small")
|
||||
// - text: the text to embed
|
||||
//
|
||||
// Returns the embedding vector as []float32, or an error.
|
||||
func GenerateEmbedding(ctx context.Context, apiHost, apiKey, model, text string) ([]float32, error) {
|
||||
if model == "" {
|
||||
return nil, fmt.Errorf("embedding model is not configured")
|
||||
}
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("text is empty")
|
||||
}
|
||||
|
||||
config := openai.DefaultConfig(apiKey)
|
||||
config.BaseURL = apiHost
|
||||
if !strings.HasSuffix(config.BaseURL, "/v1") {
|
||||
config.BaseURL += "/v1"
|
||||
}
|
||||
|
||||
log.Debugf("embedding: requesting model=%s baseURL=%s textLen=%d", model, config.BaseURL, len(text))
|
||||
|
||||
client := openai.NewClientWithConfig(config)
|
||||
|
||||
resp, err := client.CreateEmbeddings(ctx, openai.EmbeddingRequestStrings{
|
||||
Input: []string{text},
|
||||
Model: openai.EmbeddingModel(model),
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("embedding: request failed model=%s baseURL=%s err=%v", model, config.BaseURL, err)
|
||||
return nil, fmt.Errorf("create embeddings failed: %w", err)
|
||||
}
|
||||
if len(resp.Data) == 0 {
|
||||
log.Errorf("embedding: no data returned model=%s baseURL=%s", model, config.BaseURL)
|
||||
return nil, fmt.Errorf("no embedding returned")
|
||||
}
|
||||
|
||||
log.Debugf("embedding: success model=%s dimensions=%d usage={prompt=%d,total=%d}",
|
||||
model, len(resp.Data[0].Embedding), resp.Usage.PromptTokens, resp.Usage.TotalTokens)
|
||||
return resp.Data[0].Embedding, nil
|
||||
}
|
||||
Reference in New Issue
Block a user