Browse Source

refact(provider): 巨大重构 (#484)

* abc

* update base

* update

* add doc

* dnspod class provider

* lint

* fix lint

* 类型检查

* add alidns

* cloudflare

* dnspod 国际版

* Update pyproject.toml

Co-authored-by: Copilot <[email protected]>

* update dnscom

* Update dnspod.py

* fix lint

* 提供兼容的encode 和 quote 方法

* huawei dns

* 名称兼容

* join domain

* static

* update base

* add callback

* update http

* fix and update hw

* merge

* refactor: reorganize imports and enhance logging in provider modules

* refactor: update method signatures to use None as default for extra parameters

* Apply suggestions from code review

Co-authored-by: Copilot <[email protected]>

* Update ddns/provider/__init__.py

Co-authored-by: Copilot <[email protected]>

* resolve comments

* update doc

* update doc

* using self logger

* update format

* Refactor config handling and update provider docs

Refactored argument parsing and config file logic in ddns/util/config.py for clarity and improved debug handling. Updated provider development documentation to clarify standard and non-standard DNS provider requirements and examples. Added 'print' as a valid DNS provider in the v4.0 schema and improved enum formatting for log options. Minor logging and message adjustments in ddns/__main__.py.

* Rename PrintProvider to DebugProvider and update references

Renamed PrintProvider to DebugProvider and updated all references in the codebase, including configuration defaults and schema. This change standardizes the provider naming and improves clarity by using 'debug' instead of 'print' for the default DNS provider.

* Add logger support to Cache and BaseProvider classes

Introduced logger injection for Cache and BaseProvider to improve logging consistency and flexibility. Updated main entry to pass logger instances, enhanced debug log formats, and fixed a documentation reference in provider.md.

* update callback

* fix lint

* Add and refine provider authentication validation

Introduces a _validate method to BaseProvider and its subclasses to ensure authentication credentials are properly set. Refactors CallbackProvider to simplify parameter replacement and validation. Adds stricter validation for CloudflareProvider, requiring a valid email for auth_id. Updates HTTP parameter handling in BaseProvider for improved query and body management.

* fix py2

* fix import

* fix dnspod and Improve logging and robustness in DDNS provider and utils

Enhanced logging throughout the DDNS provider base and main entrypoint for better traceability, including more informative log messages and additional warning/critical logs. Improved the robustness of domain splitting and zone ID resolution logic. Updated dnspod provider methods with doc links and fixed parameter handling. Refactored config to disable cache in debug mode, and standardized string quoting and regex usage in IP utility functions for consistency.

* Mask sensitive data in logs for URL and body

Introduced the _mask_sensitive_data method to obfuscate sensitive information such as authentication tokens in URLs and request bodies before logging. Updated logging statements to use this masking for improved security.

* Add unit tests for BaseProvider and update CI workflow

Introduces a test suite for BaseProvider in tests/test_base_provider.py and initializes the tests package. Updates the GitHub Actions workflow to run unit tests as part of the build process.

* Remove unused encoding parameter from _quote method

The _quote static method in BaseProvider no longer accepts the unused 'encoding' parameter, aligning its signature with actual usage and simplifying the code.

* Remove unused 'errors' parameter from _quote method

The _quote static method in BaseProvider no longer accepts the 'errors' parameter, as it was not used. The type annotation and call to quote have been updated accordingly.

* Refactor CallbackProvider and add unit tests

Refactored CallbackProvider to improve variable replacement and HTTP method handling in set_record. Added comprehensive unit tests for CallbackProvider covering initialization, variable replacement, HTTP request logic, and error handling. Minor cleanup in get_system_info_str to remove redundant comment.

* Rename and update provider test files, add DebugProvider tests

Renamed test_base_provider.py to test_provider_base.py and test_callback_provider.py to test_provider_callback.py for consistency. Updated imports and minor formatting in test_provider_callback.py. Added comprehensive unit and integration tests for DebugProvider in new test_provider_debug.py.

* he tests

* Refactor tests to use shared BaseProviderTestCase

Introduces a new test_base.py with BaseProviderTestCase to provide common setup and utilities for provider tests. Refactors test_provider_base.py, test_provider_callback.py, test_provider_debug.py, and test_provider_he.py to inherit from BaseProviderTestCase, reducing code duplication and improving maintainability.

* Improve test configuration and Python 2.7 compatibility

Added mock installation for Python 2.7 in CI and as an optional dependency. Enhanced pyproject.toml with unified unittest and pytest configuration, coverage, and flake8 settings. Added a comprehensive tests/README.md for running and writing tests. Refactored test_provider_base.py to use _TestProvider for clarity and consistency.

* fix py2 pip

* fix pip2

* install pip

* tmp dir

* Use PY env variable for Python 2.7 setup in CI

Replaces hardcoded 'python2' with the PY environment variable in the build workflow for Python 2.7. This improves flexibility and consistency in the CI setup.

* Add stub DNS record methods to DebugProvider and HeProvider

Introduces _query_zone_id, _create_record, _update_record, and _query_record methods to DebugProvider and HeProvider. DebugProvider raises NotImplementedError for create/update, while HeProvider returns False for these methods. This prepares both providers for a unified interface and future extension.

* fix debug

* fix tests

* fix typos

* fix id check

* callback 兼容python2

* add SimpleProvider

* fix

* fix lint

* remove duplicate code

* fix lint

* fix tests

* query with condition

* fix test for py2

* fix debug tests

* run ut in more system

* add unit tests for SimpleProvider and update README

* Refactor dnspod provider and add comprehensive tests

Refactored the DnspodProvider to improve record querying logic, parameter handling, and logging. Expanded the provider development guide with detailed instructions and best practices for both SimpleProvider and BaseProvider. Added a full-featured test suite for DnspodProvider, including unit and integration tests, and updated test documentation for improved clarity and usage instructions.

* fix type

* format

* Add unit tests for AlidnsProvider and CloudflareProvider

Added comprehensive unit and integration tests for AlidnsProvider and CloudflareProvider in tests/test_provider_alidns.py and tests/test_provider_cloudflare.py. Also updated type hints in set_record methods in ddns/provider/_base.py for better type accuracy.

* Add and update provider tests; fix datetime usage

Added comprehensive unit and integration tests for DnscomProvider and HuaweiDNSProvider. Updated test authorship to GitHub Copilot. Improved test structure and naming for SimpleProvider. Fixed datetime usage in alidns.py and huaweidns.py to use timezone-aware UTC. Cleaned up and clarified test README instructions.

* Use datetime.utcnow() for UTC timestamps

Replaced usage of datetime.now(timezone.utc) with datetime.utcnow() in AlidnsProvider and HuaweiDNSProvider to avoid deprecation warnings and ensure compatibility. Updated related tests to mock datetime.utcnow() instead of datetime.now().

* Add unit tests for DnspodComProvider

Introduces unit and integration tests for the DnspodComProvider class, verifying class constants, initialization, inheritance, API endpoint differences, and default line settings.

* feat: Add TencentCloudProvider and implement related unit tests

- Introduced TencentCloudProvider for Tencent Cloud DNSPod API integration.
- Updated existing provider classes to utilize datetime.utcnow() for consistent UTC timestamp handling.
- Refactored unit tests for AlidnsProvider, CallbackProvider, DnscomProvider, and HuaweiDNSProvider to use the new now() method for timestamp generation.
- Added comprehensive unit tests for TencentCloudProvider covering initialization, validation, signature generation, record querying, creation, and updating.
- Ensured compatibility with existing functionality while enhancing test coverage and reliability.

* fix tests

* always use the time

* fix TencentCloudProvider

* fix lint

* Add new feature to process user input

Introduced a function to handle and validate user input, improving the robustness of the input processing workflow.

* Update unit tests for various DNS providers

* Refactor test imports to use base_test.py for consistency across provider tests

* Enhance CallbackProvider and HTTP utilities

- Updated CallbackProvider to include User-Agent header in requests.
- Improved handling of GET and POST methods based on the presence of auth_token.
- Added a new HTTP utilities module for better HTTP request handling, including SSL verification and redirect following.
- Implemented comprehensive unit tests for the new HTTP utilities, covering various scenarios including SSL handling, redirects, and error responses.
- Added real integration tests for CallbackProvider using httpbin.org to validate actual HTTP interactions.
- Enhanced test coverage for SimpleProvider, including verification of sensitive data masking and initialization options.

* Refactor provider constants and response handling

- Changed class constants from CamelCase (ContentType, DecodeResponse) to snake_case (content_type, decode_response) for consistency across providers.
- Updated the default value of `verify_ssl` to "auto" in SimpleProvider.
- Adjusted the initialization of providers to accept `verify_ssl` as a parameter.
- Modified the handling of headers in HTTP requests to ensure `accept` and `content-type` are set correctly.
- Updated tests to reflect changes in constant naming and initialization parameters.
- Enhanced error handling in HTTP requests to provide more informative messages.

* Refactor unit tests to use MagicMock for improved clarity and consistency

* Enhance TencentCloudProvider with improved zone ID querying and response handling in unit tests

* Refactor HTTP callback handling and enhance error logging

- Improved error handling in CallbackProvider to log exceptions and empty responses.
- Introduced HttpResponse class to encapsulate HTTP response details, including status, reason, headers, and body.
- Updated send_http_request to return HttpResponse objects instead of raw response strings.
- Enhanced tests for CallbackProvider and send_http_request to validate logging and response handling.
- Added unit tests for HttpResponse and decoding response bodies with various character sets.

* Enhance TencentCloudProvider with improved header handling and signature generation in unit tests

* Enhance error handling and logging in DnspodProvider and TencentCloudProvider unit tests

* Enhance Python 2/3 compatibility in test_util_http.py with utility functions for string encoding and decoding

* fix py2 and time

* fix tests

* disable push build

* Apply suggestions from code review

Co-authored-by: Copilot <[email protected]>

* Update tests/test_provider_cloudflare.py

Co-authored-by: Copilot <[email protected]>

* update doc

* Refactor TencentCloudProvider methods to use consistent parameter naming for subdomains; update documentation for DNSPod provider authentication methods; enhance error handling in tests to return False instead of raising exceptions for non-existent records; improve masking of sensitive data in URLs; add tests for URL-encoded sensitive data handling.

* Add Callback Provider documentation and enhance DNSPod and Tencent Cloud guides

- Introduced a comprehensive documentation for the Callback Provider, detailing configuration, request methods, variable replacements, usage scenarios, and troubleshooting tips.
- Updated DNSPod documentation to include a link to Tencent Cloud DNSPod for better user guidance.
- Added a new Tencent Cloud DNS configuration guide in both English and Chinese, outlining authentication methods, configuration examples, optional parameters, and troubleshooting steps.
- Enhanced test cases for AlidnsProvider to validate signature generation and request handling, ensuring proper HTTP interactions and error handling.
- Improved error message validation in DNSPod tests to ensure clarity in authentication failure messages.

* Enhance documentation and validation for DNS providers; add Callback Provider configuration guide and improve error handling messages for Cloudflare, DNS.COM, HE.net, and HuaweiDNS providers.

* Update tests/test_provider_callback.py

Co-authored-by: Copilot <[email protected]>

* Update tests/test_provider_dnscom.py

Co-authored-by: Copilot <[email protected]>

* Update tests/test_provider_debug.py

Co-authored-by: Copilot <[email protected]>

* Update tests/test_provider_he.py

Co-authored-by: Copilot <[email protected]>

---------

Co-authored-by: Copilot <[email protected]>
New Future 5 months ago
parent
commit
f1a764d3c2
61 changed files with 10182 additions and 1587 deletions
  1. 16 4
      .github/ISSUE_TEMPLATE/debug.md
  2. 24 3
      .github/ISSUE_TEMPLATE/new-dns-provider.md
  3. 302 0
      .github/instructions/python.instructions.md
  4. 12 14
      .github/patch.py
  5. 12 0
      .github/prompts/agent.prompt.md
  6. 17 2
      .github/workflows/build.yml
  7. 6 3
      .vscode/extensions.json
  8. 29 0
      .vscode/settings.json
  9. 86 70
      README.md
  10. 5 0
      ddns/__builtins__.pyi
  11. 4 2
      ddns/__init__.py
  12. 60 62
      ddns/__main__.py
  13. 59 0
      ddns/provider/__init__.py
  14. 558 0
      ddns/provider/_base.py
  15. 147 175
      ddns/provider/alidns.py
  16. 67 105
      ddns/provider/callback.py
  17. 95 155
      ddns/provider/cloudflare.py
  18. 20 0
      ddns/provider/debug.py
  19. 91 169
      ddns/provider/dnscom.py
  20. 105 174
      ddns/provider/dnspod.py
  21. 11 8
      ddns/provider/dnspod_com.py
  22. 41 78
      ddns/provider/he.py
  23. 147 254
      ddns/provider/huaweidns.py
  24. 240 0
      ddns/provider/tencentcloud.py
  25. 13 17
      ddns/util/cache.py
  26. 22 34
      ddns/util/config.py
  27. 277 0
      ddns/util/http.py
  28. 20 17
      ddns/util/ip.py
  29. 17 1
      doc/cli.md
  30. 299 0
      doc/dev/provider.md
  31. 144 180
      doc/docker.md
  32. 39 7
      doc/env.md
  33. 27 24
      doc/json.md
  34. 103 0
      doc/providers/README.md
  35. 180 0
      doc/providers/alidns.en.md
  36. 167 0
      doc/providers/alidns.md
  37. 257 0
      doc/providers/callback.en.md
  38. 257 0
      doc/providers/callback.md
  39. 179 0
      doc/providers/dnspod.en.md
  40. 150 0
      doc/providers/dnspod.md
  41. 193 0
      doc/providers/tencentcloud.en.md
  42. 166 0
      doc/providers/tencentcloud.md
  43. 86 23
      pyproject.toml
  44. 1 1
      run.py
  45. 22 5
      schema/v4.0.json
  46. 110 0
      tests/README.md
  47. 12 0
      tests/__init__.py
  48. 42 0
      tests/base_test.py
  49. 521 0
      tests/test_provider_alidns.py
  50. 179 0
      tests/test_provider_base.py
  51. 468 0
      tests/test_provider_callback.py
  52. 550 0
      tests/test_provider_cloudflare.py
  53. 221 0
      tests/test_provider_debug.py
  54. 419 0
      tests/test_provider_dnscom.py
  55. 510 0
      tests/test_provider_dnspod.py
  56. 74 0
      tests/test_provider_dnspod_com.py
  57. 370 0
      tests/test_provider_he.py
  58. 482 0
      tests/test_provider_huaweidns.py
  59. 310 0
      tests/test_provider_simple.py
  60. 544 0
      tests/test_provider_tencentcloud.py
  61. 597 0
      tests/test_util_http.py

+ 16 - 4
.github/ISSUE_TEMPLATE/debug.md

@@ -14,26 +14,38 @@ assignees: ''
 
 ## 版本信息 (version info)
 
-* DDNS Version: 
 * OS Version: 
 * Type(运行方式): Binary/Python2/Python3
 * related issues (相关问题): 
+* ddns --version 输出:
+
+```sh
+
+```
 
 ## **复现步骤 (To Reproduce)**
 
 
-### 配置文件 (config file)
+### 配置 (config)
+
 <!--  remove your id and token, 注意打码  -->
+<!-- 或者 贴出运行命令行参数 -->
+
 ```json
 {
 }
 ```
 
 ### 调试输出 (debug output)
-<!--  set debug: true in config, 配置文件debug 设置为true 可打印详细日志  -->
+<!-- run with --debug  -->
+运行时,命令行加上`--debug`开启调试模式
+
+
 ```sh
-粘贴输出日志
+
+粘贴输出日志,保留三个点的分割
 paste out put here
+
 ```
 
 ## 补充说明 (Additional context)

+ 24 - 3
.github/ISSUE_TEMPLATE/new-dns-provider.md

@@ -4,10 +4,31 @@ about: 接入新DNS服务商
 title: "[dns]"
 labels: NewDNSProvider
 assignees: ''
+---
+
+## DNS 服务商信息
+
+- **官网 (Website):**
+- **中文名称 (Chinese Name):**
+- **英文名称 (English Name):**
+- **标准DNS服务商 (Standard DNS Provider):**  Yes/No
+
+--
+
+## DNS 服务商文档链接
+
+请提供以下相关文档的链接(如有):
+
+- [ ] **API 认证与签名** (Authorization & Signature)
+- [ ] **查询/列出域名** (Query/List Domains)
+- [ ] **查询/列出解析记录** (Query/List DNS Records)
+- [ ] **创建解析记录** (Create DNS Record)
+- [ ] **修改解析记录** (Update DNS Record)
+- [ ] **其它配置或使用文档** (Other Configuration/Usage Docs, 可选)
+- [ ] **官方或第三方Python SDK** (Official or Third-party Python SDK, 可选)
 
 ---
 
-* DNS provider 服务商 :
-* Document of DNS Provider 文档:
-* Pull Request: #
+## 其他补充信息(可选)
 
+请补充任何有助于集成该 DNS 服务商的信息,例如常见问题、注意事项、特殊限制等。

+ 302 - 0
.github/instructions/python.instructions.md

@@ -0,0 +1,302 @@
+---
+applyTo: '**/*.py'
+---
+
+# Python Coding Standards and Best Practices
+
+## Core Principles
+
+### Dependencies and Environment
+- **Standard Library Only**: Use only Python standard library modules unless explicitly permitted
+- **Self-Contained**: Code must run without third-party dependencies on Windows, macOS, and Linux
+- **No External Dependencies**: Avoid pip packages to ensure maximum compatibility and easy deployment
+
+### Python 2.7 and 3.x Compatibility
+- **Primary Target**: Python 3.x is preferred
+- **Legacy Support**: When Python 2.7 compatibility is required, use `six` library patterns
+- **Forbidden Features**: 
+  - NO f-strings (not supported in Python 2.7)
+  - NO `async`/`await` syntax
+  - Avoid Python 3.6+ exclusive features when compatibility is needed
+
+## Project Architecture
+
+### Directory Structure
+```
+ddns/                    # Main application code
+├── provider/           # DNS provider implementations
+│   ├── _base.py       # Abstract base classes (SimpleProvider, BaseProvider)
+│   └── *.py           # Provider-specific implementations
+├── util/              # Utility functions and classes
+│   ├── http.py        # HTTP client functionality
+│   ├── config.py      # Configuration management
+│   └── *.py           # Other utilities
+└── __init__.py        # Package initialization
+
+tests/                   # Unit tests
+├── base_test.py        # Shared test utilities and base classes
+├── test_provider_*.py  # Provider-specific tests
+└── README.md          # Testing documentation
+
+doc/                     # Documentation
+├── cli.md              # Command line interface documentation
+├── docker.md           # Docker usage and deployment guide
+├── env.md              # Environment variables reference
+├── json.md             # JSON configuration format
+├── dev/                # Developer documentation
+│   └── provider.md     # Provider development guide
+├── providers/          # Provider-specific documentation
+│   ├── dnspod.md       # DNSPod configuration guide
+│   ├── cloudflare.md   # Cloudflare configuration guide
+│   └── *.md            # Other provider guides
+└── img/                # Documentation images and diagrams
+    ├── ddns.png        # Project logo and icons
+    └── ddns.svg        # Vector graphics and diagrams
+      
+schema/                  # JSON schemas
+├── v2.8.json           # Legacy configuration schema v2.8
+├── v2.json             # Legacy configuration schema v2
+└── v4.0.json           # Current configuration schema v4.0
+```
+
+### Provider Architecture
+
+#### SimpleProvider (Basic DNS Provider)
+**Purpose**: For DNS providers that only support simple record updates without querying existing records.
+
+**Must Implement**:
+- `set_record(domain, value, record_type="A", ttl=None, line=None, **extra)` 
+    - Updates or creates DNS records
+    - Should handle both creation and update logic, if supported by the provider
+    - Must return `True` on success, `False` on failure with appropriate error logging
+    - Should never raise exceptions for API failures
+
+**Optional**:
+- `_validate()` - Custom authentication validation (has default implementation)
+
+**Available Methods**:
+- `_http(method, url, ...)` - HTTP/HTTPS requests with automatic error handling
+- `_encode(params)` - URL encoding for query strings and form data
+- `_mask_sensitive_data(data)` - Log-safe data masking for security (supports URL-encoded data)
+
+#### BaseProvider (Full CRUD DNS Provider - Recommended for Most Providers)
+**Purpose**: For DNS providers supporting complete DNS record management with query capabilities.
+
+**Must Implement**:
+- `_query_zone_id(domain)` - Retrieves zone ID for a domain by calling domain info or list domains/zones API
+- `_query_record(zone_id, subdomain, main_domain, record_type, line, extra)` - Finds existing DNS record by calling list records or query record API
+- `_create_record(zone_id, subdomain, main_domain, value, record_type, ttl, line, extra)` - Creates new DNS record by calling create record API
+- `_update_record(zone_id, old_record, value, record_type, ttl, line, extra)` - Updates existing DNS record by calling update record API
+
+**Recommended Practices**:
+- Implement a `_request()` method for signed/authenticated HTTP requests:
+  - Should raise `Exception` or `RuntimeError` on blocking errors for fast failure
+  - Should return `None` or appropriate default on recoverable errors (e.g., NotFound)
+- Use `self.logger` for consistent logging throughout the provider
+
+**Inherited Methods**:
+- `_http()` - HTTP requests with authentication error handling (raises RuntimeError on 401/403)
+- `_encode()` - Parameter encoding for URL query strings and form data
+- `_join_domain(sub, main)` - Domain name construction utility
+- `set_record()` - Automatic record management (orchestrates the above abstract methods)
+
+## Code Quality Standards
+
+### Type Hints and Annotations
+```python
+# Use complete type hints for all functions
+def update_record(self, record_id, value, ttl=None):
+    # type: (str, str, int | None) -> bool
+    """Update DNS record with new value."""
+    pass
+
+# Use type annotations for class attributes
+class Provider:
+    auth_id = ""  # type: str
+```
+
+
+### Logging Best Practices
+```python
+# Use structured logging with appropriate levels and consistent formatting
+self.logger.info("Updating record: %s => %s", domain, value)
+self.logger.debug("API response: %s", response_data)
+self.logger.warning("Record not found: %s", domain)
+self.logger.error("API call failed: %s", error)
+self.logger.critical("Authentication invalid: %s", auth_error)
+
+# Always mask sensitive data in logs to prevent credential exposure
+self.logger.info("Request URL: %s", self._mask_sensitive_data(url))
+```
+
+## Documentation Standards
+
+### Documentation Guidelines
+- **User Documentation** (`doc/`): End-user guides, CLI usage, and deployment instructions
+- **Developer Documentation** (`doc/dev/`): API guides, architecture documentation, and contribution guidelines
+- **Code Documentation**: Inline docstrings and comments within source files
+- **Configuration Documentation**: JSON schemas with examples and validation rules
+
+### Docstring Format
+```python
+def create_record(self, zone_id, name, value, record_type="A"):
+    # type: (str, str, str, str) -> bool
+    """
+    Create a new DNS record in the specified zone.
+    
+    Args:
+        zone_id (str): DNS zone identifier
+        name (str): Record name (subdomain)
+        value (str): Record value (IP address, etc.)
+        record_type (str): Record type (A, AAAA, CNAME, etc.)
+        
+    Returns:
+        bool: True if creation successful, False otherwise
+        
+    Raises:
+        RuntimeError: When authentication fails (401/403 errors)
+        ValueError: When required parameters are invalid
+    """
+    pass
+```
+
+### Inline Comments
+```python
+# Explain complex business logic with clear, concise comments
+# Attempt to resolve zone automatically by walking up the domain hierarchy
+domain_parts = domain.split(".")
+for i in range(2, len(domain_parts) + 1):
+    candidate_zone = ".".join(domain_parts[-i:])
+    zone_id = self._query_zone_id(candidate_zone)
+    if zone_id:
+        break
+```
+
+## Testing Guidelines
+
+All tests must be placed in the `tests/` directory, with a shared base test class for common functionality.
+
+### Test Structure
+```python
+# tests/test_provider_example.py
+from base_test import BaseProviderTestCase, MagicMock, patch
+
+class TestExampleProvider(BaseProviderTestCase):
+    def setUp(self):
+        super(TestExampleProvider, self).setUp()
+        self.provider = ExampleProvider(self.auth_id, self.auth_token)
+    
+    @patch("ddns.provider.example._http")
+    def test_create_record_success(self, mock_http):
+        # Arrange
+        mock_http.return_value = {"id": "record123", "status": "success"}
+        
+        # Act
+        result = self.provider._create_record("zone1", "test", "example.com", "1.2.3.4", "A", None, None, {})
+        
+        # Assert
+        self.assertTrue(result)
+        mock_http.assert_called_once()
+```
+
+## Performance and Security
+
+### HTTP Client Usage
+```python
+# Always use the base class _http method for consistent behavior
+response = self._http("POST", "/api/records", 
+                     body={"name": name, "value": value},
+                     headers={"Content-Type": "application/json"})
+
+# Handle authentication errors appropriately
+# Note: 401/403 errors will automatically raise RuntimeError
+if response is None:
+    self.logger.error("API request failed")
+    return False
+```
+
+## Development Workflow
+
+### Code Validation
+- **Linting**: Use editor's Python extensions instead of command-line tools
+- **Type Checking**: Ensure Pylance compatibility
+- **Testing**: Run tests before committing changes
+- **Documentation**: Update relevant documentation when making changes
+
+### Documentation Maintenance
+- **API Changes**: Update docstrings and `doc/dev/provider.md` for provider interface changes
+- **Configuration Changes**: Update JSON schemas in `schema/` directory
+- **User-Facing Changes**: Update CLI documentation in `doc/cli.md`
+- **Examples**: Keep code examples in documentation synchronized with actual implementation
+
+### Terminal Usage Guidelines for Copilot Agent
+- **Minimize Usage**: Use terminal commands sparingly to reduce complexity
+- **Windows Compatibility**: Avoid `&&` and `||` operators that may not work on all shells
+- **No Directory Changes**: Use absolute paths instead of `cd` commands for reliability
+- **Avoid Manual Compilation**: Use editor extensions for syntax checking instead of `python -c`
+
+### Common Anti-Patterns to Avoid
+```python
+# DON'T: Use f-strings (incompatible with Python 2.7)
+error_msg = f"Failed to update {domain}"  # Not supported in Python 2.7
+
+# DO: Use .format() or % formatting for compatibility
+error_msg = "Failed to update {}".format(domain)
+error_msg = "Failed to update %s" % domain
+error_msg = "Failed to update "+ domain # Concatenation is also acceptable
+
+# DON'T: Use broad type ignores that hide potential issues
+result = api_call()  # type: ignore
+
+# DO: Use specific ignores only when absolutely necessary
+result = api_call()  # type: ignore[attr-defined]
+
+# DON'T: Return inconsistent types that confuse callers
+def get_record(self, name):
+    if found:
+        return {"id": "123", "name": name}
+    return False  # Inconsistent return type
+
+# DO: Return consistent types with clear semantics
+def get_record(self, name):
+    if found:
+        return {"id": "123", "name": name}
+    return None  # Consistent with Optional[dict]
+
+# DON'T: Use bare except clauses that hide errors
+try:
+    result = api_call()
+except:
+    return None
+
+# DO: Catch specific exceptions and handle appropriately
+try:
+    result = api_call()
+except (ValueError, TypeError) as e:
+    self.logger.error("API call failed: %s", e)
+    return None
+```
+
+## Creating a New Provider
+
+### Development Steps
+
+To create a new DNS provider, follow these steps:
+
+1. **Create a new file** in the `ddns/provider/` directory, e.g., `myprovider.py`.
+2. **Implement the provider class** inheriting from `BaseProvider` or `SimpleProvider` as appropriate based on the API capabilities.
+   * For the **BaseProvider** interface, implement these required methods:
+     - `_query_zone_id(domain)`
+     - `_query_record(zone_id, subdomain, main_domain, record_type, line, extra)`
+     - `_create_record(zone_id, subdomain, main_domain, value, record_type, ttl, line, extra)`
+     - `_update_record(zone_id, old_record, value, record_type, ttl, line, extra)`
+   * For the **SimpleProvider** interface, implement this required method:
+     - `set_record(domain, value, record_type="A", ttl=None, line=None, **extra)`
+3. **Implement the `_request()` method** for authenticated API calls.
+4. **Add the provider to the `ddns/__init__.py`** file to make it available in the main package.
+5. **Add unit tests** in the `tests/` directory to cover all methods and edge cases.
+6. **Update schema** in `schema/v4.0.json` if the provider requires new configuration options.
+7. **Create documentation** in `doc/providers/myprovider.md` for configuration and usage instructions.
+8. **Run all tests** to ensure compatibility and correctness.
+
+For detailed implementation guidance, refer to the provider development guide in `doc/dev/provider.md`.

+ 12 - 14
.github/patch.py

@@ -48,9 +48,7 @@ def update_nuitka_version(pyfile, version=None):
     with open(pyfile, "r", encoding="utf-8") as f:
         content = f.read()
     # 替换 nuitka-project 行
-    new_content, n = re.subn(
-        r"(# nuitka-project: --product-version=)[^\n]*", r"\g<1>" + pure_version, content
-    )
+    new_content, n = re.subn(r"(# nuitka-project: --product-version=)[^\n]*", r"\g<1>" + pure_version, content)
     if n > 0:
         with open(pyfile, "w", encoding="utf-8") as f:
             f.write(new_content)
@@ -163,7 +161,7 @@ def get_latest_tag():
         with urllib.request.urlopen(url) as response:
             data = json.load(response)
             if data and isinstance(data, list):
-                return data[0]['name']  # 获取第一个 tag 的 name
+                return data[0]["name"]  # 获取第一个 tag 的 name
     except Exception as e:
         print("Error fetching tag:", e)
     return None
@@ -178,13 +176,13 @@ def normalize_tag(tag: str) -> str:
 
 
 def ten_minute_bucket_id():
-    epoch_minutes = int(time.time() // 60)         # 当前时间(分钟级)
-    bucket = epoch_minutes // 10                   # 每10分钟为一个 bucket
-    return bucket % 65536                          # 限制在 0~65535 (2**16)
+    epoch_minutes = int(time.time() // 60)  # 当前时间(分钟级)
+    bucket = epoch_minutes // 10  # 每10分钟为一个 bucket
+    return bucket % 65536  # 限制在 0~65535 (2**16)
 
 
 def generate_version():
-    ref = os.environ.get('GITHUB_REF_NAME', '')
+    ref = os.environ.get("GITHUB_REF_NAME", "")
     if re.match(r"^v\d+\.\d+", ref):
         return normalize_tag(ref)
 
@@ -199,12 +197,12 @@ def generate_version():
 
 
 def replace_version_and_date(pyfile: str, version: str, date_str: str):
-    with open(pyfile, 'r', encoding="utf-8") as f:
+    with open(pyfile, "r", encoding="utf-8") as f:
         text = f.read()
         text = text.replace("${BUILD_VERSION}", version)
         text = text.replace("${BUILD_DATE}", date_str)
     if text is not None:
-        with open(pyfile, 'w', encoding="utf-8") as f:
+        with open(pyfile, "w", encoding="utf-8") as f:
             f.write(text)
             print(f"Updated {pyfile}: version={version}, date={date_str}")
     else:
@@ -215,8 +213,8 @@ def main():
     """
     遍历所有py文件并替换兼容导入,同时更新nuitka版本号
     """
-    if len(sys.argv) > 1 and sys.argv[1].lower() != 'version':
-        print(f'unknown arguments: {sys.argv}')
+    if len(sys.argv) > 1 and sys.argv[1].lower() != "version":
+        print(f"unknown arguments: {sys.argv}")
         exit(1)
     version = generate_version()
     date_str = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
@@ -225,14 +223,14 @@ def main():
 
     # 修改__init__.py 中的 __version__
     replace_version_and_date(init_py_path, version, date_str)
-    if len(sys.argv) > 1 and sys.argv[1].lower() == 'version':
+    if len(sys.argv) > 1 and sys.argv[1].lower() == "version":
         # python version only
         exit(0)
 
     run_py_path = os.path.join(ROOT, "run.py")
     update_nuitka_version(run_py_path, version)
     add_nuitka_file_description(run_py_path)
-    add_nuitka_include_modules(run_py_path)
+    # add_nuitka_include_modules(run_py_path)
 
     changed_files = 0
     for dirpath, _, filenames in os.walk(ROOT):

+ 12 - 0
.github/prompts/agent.prompt.md

@@ -0,0 +1,12 @@
+---
+mode: edit
+---
+
+# This is a prompt for an agent that can perform tasks related to the DDNS provider.
+
+run commands in terminal as less as possible, and only when necessary.
+
+prefer to update the code directly instead of running commands.
+prefer to call the extension methods directly instead of running commands.
+
+it's unnecessary to call `cd xxx &&`.

+ 17 - 2
.github/workflows/build.yml

@@ -7,7 +7,7 @@ on:
   push:
     branches: ["master", "main"]
   pull_request:
-    branches: ["master", "main"]
+    branches: ["master", "main", "abc"]
 
 permissions:
   contents: read
@@ -34,7 +34,6 @@ jobs:
         run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
       - name: check complexity and length # the GitHub editor is 127 chars wide
         run: flake8 . --count --max-complexity=12 --max-line-length=127 --statistics
-
   python:
     strategy:
       fail-fast: false
@@ -60,6 +59,18 @@ jobs:
         run: ${{env.PY}} run.py --version
       - name: test run module
         run:  ${{env.PY}} -m "ddns" -h
+      
+      - name: install mock for Python 2.7
+        if:  ${{ matrix.version == '2.7' }}
+        run: |
+          curl https://bootstrap.pypa.io/pip/2.7/get-pip.py --output get-pip.py
+          sudo ${{env.PY}} get-pip.py
+          ${{env.PY}} -m pip install mock==3.0.5
+        working-directory: /tmp
+      - name: run unit tests
+        run: ${{env.PY}} -m unittest discover tests -v
+        env:
+          PYTHONIOENCODING: utf-8
 
       - name: test patch
         if:  ${{ matrix.version != '2.7' }}
@@ -91,6 +102,8 @@ jobs:
         run: sed -i'' -E 's#([("'\''`])(/doc/[^)"'\''`]+)\.md([)"'\''`])#\1https://ddns.newfuture.cc\2.html\3#g; s#([("'\''`])/doc/#\1https://ddns.newfuture.cc/doc/#g' README.md
       - name: Build package
         run: python -m build --sdist --wheel --outdir dist/
+      - name: run unit tests
+        run: python3 -m unittest discover tests -v
 
       - uses: actions/upload-artifact@v4
         with:
@@ -140,6 +153,8 @@ jobs:
         run: python3 -m pip install imageio
    
       - run: python3 ./run.py -h
+      - name: run unit tests
+        run: python3 -m unittest discover tests -v
 
       - name: Build Executable
         uses: Nuitka/[email protected]

+ 6 - 3
.vscode/extensions.json

@@ -1,8 +1,11 @@
 {
     "recommendations": [
-        "ms-python.flake8",
-        "ms-python.autopep8",
-        "ms-python.python",
         "github.vscode-github-actions",
+        "github.vscode-pull-request-github",
+        "ms-python.python",
+        "ms-python.vscode-pylance",
+        "ms-python.black-formatter",
+        "ms-vscode.vscode-websearchforcopilot",
+        "ms-azuretools.vscode-containers"
     ]
 }

+ 29 - 0
.vscode/settings.json

@@ -0,0 +1,29 @@
+{
+    "python.analysis.autoFormatStrings": true,
+    "python.analysis.autoImportCompletions": true,
+    "python.analysis.completeFunctionParens": true,
+    "python.analysis.supportAllPythonDocuments": true,
+    "python.analysis.generateWithTypeAnnotation": true,
+    "python.analysis.diagnosticMode": "workspace",
+    "python.analysis.indexing": true,
+    "python.analysis.aiCodeActions": {
+        "implementAbstractClasses": true,
+        "addMissingImports": true,
+        "addMissingOptionalParameters": true,
+        "addMissingReturn": true,
+        "addMissingParameters": true,
+        "addMissingTypeHints": true,
+        "addMissingFunctionOverloads": true,
+        "addMissingFunctionOverloadsWithReturnType": true
+    },
+    "python.analysis.fixAll": [
+        "source.convertImportFormat",
+    ],
+    "python.formatting.provider": "black",
+    "editor.formatOnSave": true,
+    "flake8.enabled": true,
+    "editor.bracketPairColorization.enabled": true,
+    "flake8.args": [
+        "--max-line-length=120"
+    ]
+}

+ 86 - 70
README.md

@@ -22,6 +22,7 @@
   - [命令行参数](/doc/cli.md)
   - [JSON 配置文件](/doc/json.md)
   - [环境变量配置](/doc/env.md)
+  - [服务商配置指南](/doc/providers/)
 
 - 域名支持:
   - 多个域名支持
@@ -36,13 +37,15 @@
   - http 代理支持
   - 多代理自动切换
 - 服务商支持:
-  - [DNSPOD](https://www.dnspod.cn/)
-  - [阿里 DNS](http://www.alidns.com/)
+  - [DNSPOD](https://www.dnspod.cn/) ([配置指南](doc/providers/dnspod.md))
+  - [阿里 DNS](http://www.alidns.com/) ([配置指南](doc/providers/alidns.md))
   - [DNS.COM](https://www.dns.com/) (@loftor-git)
   - [DNSPOD 国际版](https://www.dnspod.com/)
   - [CloudFlare](https://www.cloudflare.com/) (@tongyifan)
   - [HE.net](https://dns.he.net/) (@NN708) (不支持自动创建记录)
   - [华为云](https://huaweicloud.com/) (@cybmp3)
+  - [腾讯云](https://cloud.tencent.com/) ([配置指南](doc/providers/tencentcloud.md))
+  - 自定义回调 API ([配置指南](doc/providers/callback.md))
 - 其他:
   - 可设置定时任务
   - TTL 配置支持
@@ -57,60 +60,66 @@
 
 推荐 Docker 版,兼容性最佳,体积小,性能优化。
 
-- #### pip 安装(需要 pip 或 easy_install
+- #### Docker(需要安装 Docker
 
-  1. 安装 ddns: `pip install ddns` 或 `easy_install ddns`
-  2. 运行: `ddns`
+  详细说明和高级用法请查看 [Docker 使用文档](/doc/docker.md)
 
-- #### 二进制版(单文件,无需 python)
+  <details>
+  <summary markdown="span">支持命令行,配置文件,和环境变量传参</summary>
 
-  - Windows [ddns.exe](https://github.com/NewFuture/DDNS/releases/latest)
-  - Linux(仅 Ubuntu 测试) [ddns](https://github.com/NewFuture/DDNS/releases/latest)
-  - Mac OSX [ddns-mac](https://github.com/NewFuture/DDNS/releases/latest)
+  - 命令行cli
 
-- #### 源码运行(无任何依赖,需 python 环境)
+      ```sh
+      docker run newfuture/ddns -h
+      ```
 
-  1. clone 或者 [下载此仓库](https://github.com/NewFuture/DDNS/archive/master.zip) 并解压
-  2. 运行 ./run.py(windows 双击 `run.bat` 或者运行 `python run.py`)
+  - 使用配置文件(docker 工作目录 `/ddns/`,默认配置位置 `/ddns/config.json`):
 
-- #### Docker(需要安装 Docker)
+      ```sh
+      docker run -d -v /host/config/:/ddns/ --network host newfuture/ddns
+      ```
 
   - 使用环境变量:
 
-    ```sh
-    docker run -d \
-      -e DDNS_DNS=dnspod \
-      -e DDNS_ID=12345 \
-      -e DDNS_TOKEN=mytokenkey \
-      -e DDNS_IPV4=ddns.newfuture.cc \
-      -e DDNS_IPV6=ddns.newfuture.cc \
-      --network host \
-      newfuture/ddns
-    ```
+      ```sh
+      docker run -d \
+        -e DDNS_DNS=dnspod \
+        -e DDNS_ID=12345 \
+        -e DDNS_TOKEN=mytokenkey \
+        -e DDNS_IPV4=ddns.newfuture.cc \
+        --network host \
+        newfuture/ddns
+      ```
 
-  - 使用配置文件(docker 工作目录 `/ddns/`,默认配置位置 `/ddns/config.json`):
+  </details>
+
+- #### pip 安装(需要 pip 或 easy_install)
+
+  1. 安装 ddns: `pip install ddns` 或 `easy_install ddns`
+  2. 运行: `ddns -h` 或者 `python -m ddns`
 
-    ```sh
-    docker run -d \
-      -v /local/config/path/:/ddns/ \
-      --network host \
-      newfuture/ddns
-    ```
+- #### 二进制版(单文件,无需 python)
+
+  前往[release下载对应版本](https://github.com/NewFuture/DDNS/releases/latest)
 
-  更多详细说明和高级用法请查看 [Docker 使用文档](doc/docker.md)。
+- #### 源码运行(无任何依赖,需 python 环境)
+
+  1. clone 或者 [下载此仓库](https://github.com/NewFuture/DDNS/archive/master.zip) 并解压
+  2. 运行 `python run.py` 或者 `python -m ddns`
 
 ### ② 快速配置
 
 1. 申请 api `token`,填写到对应的 `id` 和 `token` 字段:
 
-   - [DNSPOD(国内版)创建 token](https://support.dnspod.cn/Kb/showarticle/tsid/227/)
-   - [阿里云 accesskey](https://help.aliyun.com/document_detail/87745.htm)
-   - [DNS.COM API Key/Secret](https://www.dns.com/member/apiSet)
-   - [DNSPOD(国际版)](https://www.dnspod.com/docs/info.html#get-the-user-token)
-   - [CloudFlare API Key](https://support.cloudflare.com/hc/en-us/articles/200167836-Where-do-I-find-my-Cloudflare-API-key-)(除了 `email + API KEY`,也可使用 `Token`,需要列出 Zone 权限)
-   - [HE.net DDNS 文档](https://dns.he.net/docs.html)(仅需将设置的密码填入 `token` 字段,`id` 字段可留空)
-   - [华为 APIKEY 申请](https://console.huaweicloud.com/iam/)(点左边访问密钥,然后点新增访问密钥)
-   - 自定义回调的参数填写方式请查看下方的自定义回调配置说明
+   - **DNSPOD(中国版)**: [创建 token](https://support.dnspod.cn/Kb/showarticle/tsid/227/) | [详细配置文档](doc/providers/dnspod.md)
+   - **阿里云 DNS**: [申请 accesskey](https://help.aliyun.com/document_detail/87745.htm) | [详细配置文档](doc/providers/alidns.md)
+   - **DNS.COM**: [API Key/Secret](https://www.dns.com/member/apiSet)
+   - **DNSPOD(国际版)**: [获取 token](https://www.dnspod.com/docs/info.html#get-the-user-token)
+   - **CloudFlare**: [API Key](https://support.cloudflare.com/hc/en-us/articles/200167836-Where-do-I-find-my-Cloudflare-API-key-)(除了 `email + API KEY`,也可使用 `Token`,**需要list Zone 权限**)
+   - **HE.net**: [DDNS 文档](https://dns.he.net/docs.html)(仅需将设置的密码填入 `token` 字段,`id` 字段可留空)
+   - **华为云 DNS**: [APIKEY 申请](https://console.huaweicloud.com/iam/)(点左边访问密钥,然后点新增访问密钥)
+   - **腾讯云 DNS**: [详细配置文档](doc/providers/tencentcloud.md)
+   - **自定义回调**: 参数填写方式请查看下方的自定义回调配置说明
 
 2. 修改配置文件,`ipv4` 和 `ipv6` 字段,为待更新的域名,详细参照配置说明
 
@@ -130,12 +139,13 @@
 - **JSON配置文件**:介于命令行和环境变量之间,会覆盖环境变量中的设置
 - **环境变量**:优先级最低,当其他方式未设置时使用
 
-**特殊情况**:
+**高级用法**:
+
 - JSON配置中明确设为`null`的值会覆盖环境变量设置
 - `debug`参数只在命令行中有效,JSON配置文件中的同名设置无效
 - 多值参数(如`ipv4`、`ipv6`等)在命令行中使用方式为重复使用参数,如`--ipv4 domain1 --ipv4 domain2`
 
-各配置方式的详细说明请查看对应文档:[命令行](doc/cli.md)、[JSON配置](doc/json.md)、[环境变量](doc/env.md)
+各配置方式的详细说明请查看对应文档:[命令行](doc/cli.md)、[JSON配置](doc/json.md)、[环境变量](doc/env.md)、[服务商配置](doc/providers/)
 
 > 📖 **环境变量详细配置**: 查看 [环境变量配置文档](doc/env.md) 了解所有环境变量的详细用法和示例
 
@@ -149,26 +159,26 @@
 
 ```bash
 ddns -c path/to/config.json
-# 或者源码运行
-python run.py -c /path/to/config.json
+# 或者python运行
+python -m ddns -c /path/to/config.json
 ```
 
 #### 配置参数表
 
-|  key     |        type        | required |   default   |    description    | tips                                                                                                        |
-| :------: | :----------------: | :------: | :---------: | :---------------: | ----------------------------------------------------------------------------------------------------------- |
-|   id     |       string       |    √     |     无      |    api 访问 ID    | Cloudflare 为邮箱(使用 Token 时留空)<br>HE.net 可留空<br>华为云为 Access Key ID (AK)                     |
-|  token   |       string       |    √     |     无      |  api 授权 token   | 部分平台叫 secret key,**反馈粘贴时删除**                                                                   |
-|  dns     |       string       |    No    | `"dnspod"`  |    dns 服务商     | 阿里 DNS 为 `alidns`,Cloudflare 为 `cloudflare`,dns.com 为 `dnscom`,DNSPOD 国内为 `dnspod`,DNSPOD 国际为 `dnspod_com`,HE.net 为 `he`,华为云为 `huaweidns`,自定义回调为 `callback` |
-|  ipv4    |       array        |    No    |    `[]`     |   ipv4 域名列表   | 为 `[]` 时,不会获取和更新 IPv4 地址                                                                        |
-|  ipv6    |       array        |    No    |    `[]`     |   ipv6 域名列表   | 为 `[]` 时,不会获取和更新 IPv6 地址                                                                        |
-| index4   | string\|int\|array |    No    | `"default"` |   ipv4 获取方式   | 可设置 `网卡`、`内网`、`公网`、`正则` 等方式                                                                |
-| index6   | string\|int\|array |    No    | `"default"` |   ipv6 获取方式   | 可设置 `网卡`、`内网`、`公网`、`正则` 等方式                                                                |
-|  ttl     |       number       |    No    |   `null`    | DNS 解析 TTL 时间 | 不设置采用 DNS 默认策略                                                                                     |
-|  proxy   |       string\|array       |    No    |     无      | http 代理 `;` 分割 | 多代理逐个尝试直到成功,`DIRECT` 为直连                                                                     |
-|  debug  |        bool        |    No    |   `false`   |   是否开启调试    | 等同于设置 log.level=DEBUG,仅命令行参数`--debug`有效                                                  |
-|  cache   |    string\|bool    |    No    |   `true`    |   是否缓存记录    | 正常情况打开避免频繁更新,默认位置为临时目录下 `ddns.cache`,也可以指定一个具体路径                          |
-|  log     | object | No | `null` | 日志配置(可选) | 日志配置对象,支持`level`、`file`、`format`、`datefmt`参数                   |
+|  key   |        type        | required |   default   |    description     | tips                                                                                                                                                                                     |
+| :----: | :----------------: | :------: | :---------: | :----------------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+|   id   |       string       |    √     |     无      |    api 访问 ID     | Cloudflare 为邮箱(使用 Token 时留空)<br>HE.net 可留空<br>华为云为 Access Key ID (AK)                                                                                                   |
+| token  |       string       |    √     |     无      |   api 授权 token   | 部分平台叫 secret key,**反馈粘贴时删除**                                                                                                                                                |
+|  dns   |       string       |    No    | `"dnspod"`  |     dns 服务商     | 阿里 DNS 为 `alidns`,Cloudflare 为 `cloudflare`,dns.com 为 `dnscom`,DNSPOD 国内为 `dnspod`,DNSPOD 国际为 `dnspod_com`,HE.net 为 `he`,华为云为 `huaweidns`,腾讯云为 `tencentcloud`,自定义回调为 `callback`。部分服务商有[详细配置文档](doc/providers/) |
+|  ipv4  |       array        |    No    |    `[]`     |   ipv4 域名列表    | 为 `[]` 时,不会获取和更新 IPv4 地址                                                                                                                                                     |
+|  ipv6  |       array        |    No    |    `[]`     |   ipv6 域名列表    | 为 `[]` 时,不会获取和更新 IPv6 地址                                                                                                                                                     |
+| index4 | string\|int\|array |    No    | `"default"` |   ipv4 获取方式    | 可设置 `网卡`、`内网`、`公网`、`正则` 等方式                                                                                                                                             |
+| index6 | string\|int\|array |    No    | `"default"` |   ipv6 获取方式    | 可设置 `网卡`、`内网`、`公网`、`正则` 等方式                                                                                                                                             |
+|  ttl   |       number       |    No    |   `null`    | DNS 解析 TTL 时间  | 不设置采用 DNS 默认策略                                                                                                                                                                  |
+| proxy  |   string\|array    |    No    |     无      | http 代理 `;` 分割 | 多代理逐个尝试直到成功,`DIRECT` 为直连                                                                                                                                                  |
+| debug  |        bool        |    No    |   `false`   |    是否开启调试    | 调试模式,仅命令行参数`--debug`有效                                                                                                                                    |
+| cache  |    string\|bool    |    No    |   `true`    |    是否缓存记录    | 正常情况打开避免频繁更新,默认位置为临时目录下 `ddns.cache`,也可以指定一个具体路径                                                                                                      |
+|  log   |       object       |    No    |   `null`    |  日志配置(可选)  | 日志配置对象,支持`level`、`file`、`format`、`datefmt`参数                                                                                                                               |
 
 #### index4 和 index6 参数说明
 
@@ -187,16 +197,18 @@ python run.py -c /path/to/config.json
 
 #### 自定义回调配置说明
 
-- `id` 字段填写回调地址,以 HTTP 或 HTTPS 开头,推荐采用 HTTPS 方式的回调 API ,当 `token` 字段非空且 URL 参数包含下表所示的常量字符串时,会自动替换为实际内容。
-- `token` 字段为 POST 参数,本字段为空或不存在则使用 GET 方式发起回调,回调参数采用 JSON 格式编码,当 JSON 的首层参数值包含下表所示的常量字符串时,会自动替换为实际内容。
+- `id` 字段填写回调地址,以 HTTP 或 HTTPS 开头,推荐采用 HTTPS 方式的回调 API,支持变量替换功能。
+- `token` 字段为 POST 请求参数(JSON对象或JSON字符串),本字段为空或不存在则使用 GET 方式发起回调。当 JSON 的参数值包含下表所示的常量字符串时,会自动替换为实际内容。
+
+详细配置指南请查看:[Callback Provider 配置文档](doc/providers/callback.md)
 
-| 常量名称          | 常量内容               | 说明      |
-| ---------------- | ---------------------- | -------- |
-| `__DOMAIN__`     | DDNS 域名              |          |
-| `__RECORDTYPE__` | DDNS 记录类型           |          |
-| `__TTL__`        | DDNS TTL               |          |
-| `__TIMESTAMP__`  | 请求发起时间戳          | 包含小数 |
+| 常量名称         | 常量内容                 | 说明     |
+| ---------------- | ------------------------ | -------- |
+| `__DOMAIN__`     | DDNS 域名                |          |
 | `__IP__`         | 获取的对应类型的 IP 地址 |          |
+| `__RECORDTYPE__` | DDNS 记录类型            |          |
+| `__TTL__`        | DDNS TTL                 |          |
+| `__TIMESTAMP__`  | 请求发起时间戳           | 包含小数 |
 
 #### 配置示例
 
@@ -205,21 +217,21 @@ python run.py -c /path/to/config.json
   "$schema": "https://ddns.newfuture.cc/schema/v4.0.json",
   "id": "12345",
   "token": "mytokenkey",
-  "dns": "dnspod 或 dnspod_com 或 alidns 或 dnscom 或 cloudflare 或 he 或 huaweidns 或 callback",
+  "dns": "dnspod 或 dnspod_com 或 alidns 或 dnscom 或 cloudflare 或 he 或 huaweidns 或 tencentcloud 或 callback",
   "ipv4": ["ddns.newfuture.cc", "ipv4.ddns.newfuture.cc"],
   "ipv6": ["ddns.newfuture.cc", "ipv6.ddns.newfuture.cc"],
   "index4": 0,
   "index6": "public",
   "ttl": 600,
-  "proxy": "127.0.0.1:1080;DIRECT",
+  "proxy": ["127.0.0.1:1080", "DIRECT"],
   "log": {
     "level": "DEBUG",
     "file": "dns.log",
-    "format": "%(asctime)s %(levelname)s [%(module)s]: %(message)s",
     "datefmt": "%Y-%m-%dT%H:%M:%S"
   }
 }
 ```
+
 </details>
 
 ## 定时任务
@@ -235,10 +247,13 @@ python run.py -c /path/to/config.json
 #### Linux
 
 - 使用 init.d 和 crontab:
+
   ```bash
   sudo ./task.sh
   ```
+
 - 使用 systemd:
+
   ```bash
   安装:
   sudo ./systemd.sh install
@@ -271,6 +286,7 @@ Docker 镜像在无额外参数的情况下,已默认启用每 5 分钟执行
 - dnspod.cn 打开: <https://dnsapi.cn>
 - dnspod 国际版: <https://api.dnspod.com>
 - 华为 DNS <https://dns.myhuaweicloud.com>
+
 </details>
 
 <details>
@@ -279,8 +295,8 @@ Docker 镜像在无额外参数的情况下,已默认启用每 5 分钟执行
 1. 先确认排查是否是系统/网络环境问题
 2. 在 [issues](https://github.com/NewFuture/DDNS/issues) 中搜索是否有类似问题
 3. 前两者均无法解决或者确定是 bug,[在此新建 issue](https://github.com/NewFuture/DDNS/issues/new)
-   - [ ] 开启 debug 配置
+   - [ ] 开启 `--debug`
    - [ ] 附上这些内容 **运行版本和方式**、**系统环境**、**出错日志**、**去掉 id/token** 的配置文件
    - [ ] 源码运行注明使用的 python 环境
 
-</details>
+</details>

+ 5 - 0
ddns/__builtins__.pyi

@@ -0,0 +1,5 @@
+# coding=utf-8
+# flake8: noqa: F401
+from typing import *
+from .provider import SimpleProvider
+import logging

+ 4 - 2
ddns/__init__.py

@@ -15,5 +15,7 @@ __doc__ = """
 ddns [v{}@{}]
 (i) homepage or docs [文档主页]: https://ddns.newfuture.cc/
 (?) issues or bugs [问题和反馈]: https://github.com/NewFuture/DDNS/issues
-Copyright (c) New Future (MIT License)
-""".format(__version__, build_date)
+Copyright (c) NewFuture (MIT License)
+""".format(
+    __version__, build_date
+)

+ 60 - 62
ddns/__main__.py

@@ -1,15 +1,14 @@
 # -*- coding:utf-8 -*-
 """
 DDNS
-@author: New Future
-@modified: rufengsuixing
+@author: NewFuture, rufengsuixing
 """
 
 from os import path, environ, name as os_name
 from io import TextIOWrapper
 from subprocess import check_output
 from tempfile import gettempdir
-from logging import basicConfig, info, warning, error, debug, INFO
+from logging import basicConfig, getLogger, info, error, debug, warning, INFO
 
 import sys
 
@@ -17,6 +16,7 @@ from .__init__ import __version__, __description__, __doc__, build_date
 from .util import ip
 from .util.cache import Cache
 from .util.config import init_config, get_config
+from .provider import get_provider_class, SimpleProvider  # noqa: F401
 
 environ["DDNS_VERSION"] = __version__
 
@@ -27,8 +27,8 @@ def is_false(value):
     字符串 'false', 或者 False, 或者 'none';
     0 不是 False
     """
-    if isinstance(value, str):
-        return value.strip().lower() in ['false', 'none']
+    if hasattr(value, "strip"):  # 字符串
+        return value.strip().lower() in ["false", "none"]
     return value is False
 
 
@@ -50,14 +50,13 @@ def get_ip(ip_type, index="default"):
                     break
         elif str(index).isdigit():  # 数字 local eth
             value = getattr(ip, "local_v" + ip_type)(index)
-        elif index.startswith('cmd:'):  # cmd
-            value = str(check_output(index[4:]).strip().decode('utf-8'))
-        elif index.startswith('shell:'):  # shell
-            value = str(check_output(
-                index[6:], shell=True).strip().decode('utf-8'))
-        elif index.startswith('url:'):  # 自定义 url
+        elif index.startswith("cmd:"):  # cmd
+            value = str(check_output(index[4:]).strip().decode("utf-8"))
+        elif index.startswith("shell:"):  # shell
+            value = str(check_output(index[6:], shell=True).strip().decode("utf-8"))
+        elif index.startswith("url:"):  # 自定义 url
             value = getattr(ip, "public_v" + ip_type)(index[4:])
-        elif index.startswith('regex:'):  # 正则 regex
+        elif index.startswith("regex:"):  # 正则 regex
             value = getattr(ip, "regex_v" + ip_type)(index[6:])
         else:
             value = getattr(ip, index + "_v" + ip_type)()
@@ -67,46 +66,48 @@ def get_ip(ip_type, index="default"):
 
 
 def change_dns_record(dns, proxy_list, **kw):
+    # type: (SimpleProvider, list, **(str)) -> bool
     for proxy in proxy_list:
-        if not proxy or (proxy.upper() in ['DIRECT', 'NONE']):
-            dns.Config.PROXY = None
+        if not proxy or (proxy.upper() in ["DIRECT", "NONE"]):
+            dns.set_proxy(None)
         else:
-            dns.Config.PROXY = proxy
-        record_type, domain = kw['record_type'], kw['domain']
-        info("%s(%s) ==> %s [via %s]", domain, record_type, kw['ip'], proxy)
+            dns.set_proxy(proxy)
+        record_type, domain = kw["record_type"], kw["domain"]
         try:
-            return dns.update_record(domain, kw['ip'], record_type=record_type)
+            return dns.set_record(domain, kw["ip"], record_type=record_type, ttl=kw["ttl"])
         except Exception as e:
             error("Failed to update %s record for %s: %s", record_type, domain, e)
     return False
 
 
-def update_ip(ip_type, cache, dns, proxy_list):
+def update_ip(ip_type, cache, dns, ttl, proxy_list):
+    # type: (str, Cache | None, SimpleProvider, str, list[str]) -> bool | None
     """
     更新IP
     """
-    ipname = 'ipv' + ip_type
+    ipname = "ipv" + ip_type
     domains = get_config(ipname)
     if not domains:
         return None
     if not isinstance(domains, list):
-        domains = domains.strip('; ').replace(',', ';').replace(' ', ';').split(';')
+        domains = domains.strip("; ").replace(",", ";").replace(" ", ";").split(";")
 
-    index_rule = get_config('index' + ip_type, "default")
+    index_rule = get_config("index" + ip_type, "default")  # type: str # type: ignore
     address = get_ip(ip_type, index_rule)
     if not address:
-        error('Fail to get %s address!', ipname)
+        error("Fail to get %s address!", ipname)
         return False
 
     if cache and (address == cache.get(ipname)):
-        info('%s address not changed, using cache.', ipname)
+        info("%s address not changed, using cache.", ipname)
         return True
 
-    record_type = 'A' if ip_type == '4' else 'AAAA'
+    record_type = "A" if ip_type == "4" else "AAAA"
     update_success = False
     for domain in domains:
         domain = domain.lower()
-        if change_dns_record(dns, proxy_list, domain=domain, ip=address, record_type=record_type):
+        if change_dns_record(dns, proxy_list, domain=domain, ip=address, record_type=record_type, ttl=ttl):
+            warning("set %s[IPv%s]: %s successfully.", domain, ip_type, address)
             update_success = True
 
     if isinstance(cache, dict):
@@ -120,68 +121,65 @@ def main():
     更新
     """
     encode = sys.stdout.encoding
-    if encode is not None and encode.lower() != 'utf-8' and hasattr(sys.stdout, 'buffer'):
+    if encode is not None and encode.lower() != "utf-8" and hasattr(sys.stdout, "buffer"):
         # 兼容windows 和部分ASCII编码的老旧系统
-        sys.stdout = TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
-        sys.stderr = TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
+        sys.stdout = TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
+        sys.stderr = TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
     init_config(__description__, __doc__, __version__, build_date)
 
-    log_level = get_config('log.level', INFO)
-    log_format = get_config('log.format')
+    log_level = get_config("log.level", INFO)  # type: int # type: ignore
+    log_format = get_config("log.format")  # type: str | None # type: ignore
     if log_format:
         # A custom log format is already set; no further action is required.
         pass
     elif log_level < INFO:
         # Override log format in debug mode to include filename and line number for detailed debugging
-        log_format = '%(asctime)s %(levelname)s [%(module)s.%(funcName)s](%(filename)s:%(lineno)d): %(message)s'
+        log_format = "%(asctime)s %(levelname)s [%(name)s.%(funcName)s](%(filename)s:%(lineno)d): %(message)s"
     elif log_level > INFO:
-        log_format = '%(asctime)s %(levelname)s: %(message)s'
+        log_format = "%(asctime)s %(levelname)s: %(message)s"
     else:
-        log_format = '%(asctime)s %(levelname)s [%(module)s]: %(message)s'
+        log_format = "%(asctime)s %(levelname)s [%(name)s]: %(message)s"
     basicConfig(
         level=log_level,
         format=log_format,
-        datefmt=get_config('log.datefmt', '%Y-%m-%dT%H:%M:%S'),
-        filename=get_config('log.file'),
+        datefmt=get_config("log.datefmt", "%Y-%m-%dT%H:%M:%S"),  # type: ignore
+        filename=get_config("log.file"),  # type: ignore
     )
+    logger = getLogger()
+    logger.name = "ddns"
 
-    info("DDNS[ %s ] run: %s %s", __version__, os_name, sys.platform)
+    debug("DDNS[ %s ] run: %s %s", __version__, os_name, sys.platform)
 
-    # Dynamically import the dns module as configuration
-    dns_provider = str(get_config('dns', 'dnspod').lower())
-    # dns_module = __import__(
-    #     '.dns', fromlist=[dns_provider], package=__package__)
-    dns = getattr(__import__('ddns.provider', fromlist=[dns_provider]), dns_provider)
-    # dns = getattr(dns_module, dns_provider)
-    dns.Config.ID = get_config('id')
-    dns.Config.TOKEN = get_config('token')
-    dns.Config.TTL = get_config('ttl')
+    # dns provider class
+    dns_name = get_config("dns", "debug")  # type: str # type: ignore
+    provider_class = get_provider_class(dns_name)
+    dns = provider_class(get_config("id"), get_config("token"), logger=logger)  # type: ignore
 
     if get_config("config"):
-        info('loaded Config from: %s', path.abspath(get_config('config')))
+        info("loaded Config from: %s", path.abspath(get_config("config")))  # type: ignore
 
-    proxy = get_config('proxy') or 'DIRECT'
-    proxy_list = proxy if isinstance(
-        proxy, list) else proxy.strip(';').replace(',', ';').split(';')
+    proxy = get_config("proxy") or "DIRECT"
+    proxy_list = proxy if isinstance(proxy, list) else proxy.strip(";").replace(",", ";").split(";")
 
-    cache_config = get_config('cache', True)
+    cache_config = get_config("cache", True)  # type: bool | str  # type: ignore
     if cache_config is False:
-        cache = cache_config
+        cache = None
     elif cache_config is True:
-        cache = Cache(path.join(gettempdir(), 'ddns.cache'))
+        cache = Cache(path.join(gettempdir(), "ddns.cache"), logger)
     else:
-        cache = Cache(cache_config)
+        cache = Cache(cache_config, logger)
 
-    if cache is False:
-        info('Cache is disabled!')
-    elif not get_config('config_modified_time') or get_config('config_modified_time') >= cache.time:
-        warning('Cache file is outdated.')
+    if cache is None:
+        info("Cache is disabled!")
+    elif get_config("config_modified_time", float("inf")) >= cache.time:  # type: ignore
+        info("Cache file is outdated.")
         cache.clear()
     else:
-        debug('Cache is empty.')
-    update_ip('4', cache, dns, proxy_list)
-    update_ip('6', cache, dns, proxy_list)
+        debug("Cache is empty.")
+    ttl = get_config("ttl")  # type: str # type: ignore
+    update_ip("4", cache, dns, ttl, proxy_list)
+    update_ip("6", cache, dns, ttl, proxy_list)
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     main()

+ 59 - 0
ddns/provider/__init__.py

@@ -0,0 +1,59 @@
+# coding=utf-8
+from ._base import SimpleProvider  # noqa: F401
+from .alidns import AlidnsProvider
+from .callback import CallbackProvider
+from .cloudflare import CloudflareProvider
+from .dnscom import DnscomProvider
+from .dnspod import DnspodProvider
+from .dnspod_com import DnspodComProvider
+from .he import HeProvider
+from .huaweidns import HuaweiDNSProvider
+from .tencentcloud import TencentCloudProvider
+from .debug import DebugProvider
+
+
+def get_provider_class(provider_name):
+    # type: (str) -> type[SimpleProvider]
+    """
+    获取指定的DNS提供商类
+
+    :param provider_name: 提供商名称
+    :return: 对应的DNS提供商类
+    """
+    provider_name = str(provider_name).lower()
+    mapping = {
+        # dnspod.cn
+        "dnspod": DnspodProvider,
+        "dnspod_cn": DnspodProvider,  # 兼容旧的dnspod_cn
+        # dnspod.com
+        "dnspod_com": DnspodComProvider,
+        "dnspod_global": DnspodComProvider,  # 兼容旧的dnspod_global
+        # tencent cloud dnspod
+        "tencentcloud": TencentCloudProvider,
+        "tencent": TencentCloudProvider,  # 兼容tencent
+        "qcloud": TencentCloudProvider,  # 兼容qcloud
+        # cloudflare
+        "cloudflare": CloudflareProvider,
+        # aliyun alidns
+        "alidns": AlidnsProvider,
+        "aliyun": AlidnsProvider,  # 兼容aliyun
+        # dns.com
+        "dnscom": DnscomProvider,
+        "51dns": DnscomProvider,  # 兼容旧的51dns
+        "dns_com": DnscomProvider,  # 兼容旧的dns_com
+        # he.net
+        "he": HeProvider,
+        "he_net": HeProvider,  # 兼容he.net
+        # huawei
+        "huaweidns": HuaweiDNSProvider,
+        "huawei": HuaweiDNSProvider,  # 兼容huawei
+        "huaweicloud": HuaweiDNSProvider,
+        # callback
+        "callback": CallbackProvider,
+        "webhook": CallbackProvider,  # 兼容
+        "http": CallbackProvider,  # 兼容
+        # debug
+        "print": DebugProvider,
+        "debug": DebugProvider,  # 兼容print
+    }
+    return mapping.get(provider_name)  # type: ignore[return-value]

+ 558 - 0
ddns/provider/_base.py

@@ -0,0 +1,558 @@
+# coding=utf-8
+"""
+## SimpleProvider 简单DNS抽象基类
+
+* set_record()
+
+## BaseProvider 标准DNS抽象基类
+定义所有 DNS 服务商 API 类应继承的抽象基类,统一接口,便于扩展适配多服务商。
+
+Abstract base class for DNS provider APIs.
+Defines a unified interface to support extension and adaptation across providers.
+* _query_zone_id
+* _query_record
+* _update_record
+* _create_record
+┌──────────────────────────────────────────────────┐
+│        用户调用 set_record(domain, value...)      │
+└──────────────────────────────────────────────────┘
+                      │
+                      ▼
+     ┌──────────────────────────────────────┐
+     │   快速解析 是否包含 ~ 或 + 分隔符?    │
+     └──────────────────────────────────────┘
+            │                         │
+       [是,拆解成功]             [否,无法拆解]
+ sub 和 main│                         │ domain
+            ▼                         ▼
+┌────────────────────────┐   ┌──────────────────────────┐
+│ 查询 zone_id           │   │ 自动循环解析   while:     │
+│  _query_zone_id(main)  │   │  _query_zone_id(...)     │
+└────────────────────────┘   └──────────────────────────┘
+            │                         │
+            ▼                         ▼
+      zone_id ←──────────────┬─── sub
+                             ▼
+        ┌─────────────────────────────────────┐
+        │ 查询 record:                        │
+        │   _query_record(zone_id, sub, ...)  │
+        └─────────────────────────────────────┘
+                          │
+            ┌─────────────┴────────────────┐
+            │    record_id 是否存在?       │
+            └────────────┬─────────────────┘
+                         │
+          ┌──────────────┴─────────────┐
+          │                            │
+          ▼                            ▼
+┌─────────────────────┐      ┌─────────────────────┐
+│ 更新记录             │      │ 创建记录            │
+│ _update_record(...) │      │ _create_record(...) │
+└─────────────────────┘      └─────────────────────┘
+          │                            │
+          ▼                            ▼
+        ┌───────────────────────────────┐
+        │         返回操作结果           │
+        └───────────────────────────────┘
+@author: NewFuture
+"""
+
+from os import environ
+from abc import ABCMeta, abstractmethod
+from json import loads as jsondecode, dumps as jsonencode
+from logging import Logger, getLogger  # noqa:F401 # type: ignore[no-redef]
+from ..util.http import send_http_request
+
+try:  # python 3
+    from urllib.parse import quote, urlencode
+except ImportError:  # python 2
+    from urllib import urlencode, quote  # type: ignore[no-redef,import-untyped]
+
+TYPE_FORM = "application/x-www-form-urlencoded"
+TYPE_JSON = "application/json"
+
+
+class SimpleProvider(object):
+    """
+    简单DNS服务商接口的抽象基类, 必须实现 `set_record` 方法。
+
+    Abstract base class for all simple DNS provider APIs.
+    Subclasses must implement `set_record`.
+
+    * set_record(domain, value, record_type="A", ttl=None, line=None, **extra)
+    """
+
+    __metaclass__ = ABCMeta
+
+    # API endpoint domain (to be defined in subclass)
+    API = ""  # type: str # https://exampledns.com
+    # Content-Type for requests (to be defined in subclass)
+    content_type = TYPE_FORM  # type: Literal["application/x-www-form-urlencoded"] | Literal["application/json"]
+    # 默认 accept 头部, 空则不设置
+    accept = TYPE_JSON  # type: str | None
+    # Decode Response as JSON by default
+    decode_response = True
+    # 是否验证 SSL 证书,默认为 True
+    verify_ssl = "auto"  # type: bool | str
+
+    # 版本
+    version = environ.get("DDNS_VERSION", "0.0.0")
+    # Description
+    remark = "Managed by [DDNS v{}](https://ddns.newfuture.cc)".format(version)
+
+    def __init__(self, auth_id, auth_token, logger=None, verify_ssl=None, **options):
+        # type: (str, str, Logger | None, bool|str| None, **object) -> None
+        """
+        初始化服务商对象
+
+        Initialize provider instance.
+
+        Args:
+            auth_id (str): 身份认证 ID / Authentication ID
+            auth_token (str): 密钥 / Authentication Token
+            options (dict): 其它参数,如代理、调试等 / Additional options
+        """
+        self.auth_id = auth_id  # type: str
+        self.auth_token = auth_token  # type: str
+        self.options = options
+        name = self.__class__.__name__
+        self.logger = (logger or getLogger()).getChild(name)
+        self.proxy = None  # type: str | None
+        if verify_ssl is not None:
+            self.verify_ssl = verify_ssl
+        self._zone_map = {}  # type: dict[str, str]
+        self.logger.debug("%s initialized with: %s", self.__class__.__name__, auth_id)
+        self._validate()  # 验证身份认证信息
+
+    @abstractmethod
+    def set_record(self, domain, value, record_type="A", ttl=None, line=None, **extra):
+        # type: (str, str, str, str | int | None, str | None, **object) -> bool
+        """
+        设置 DNS 记录(创建或更新)
+
+        Set or update DNS record.
+
+        Args:
+            domain (str): 完整域名
+            value (str): 新记录值
+            record_type (str): 记录类型
+            ttl (int | None): TTL 值,可选
+            line (str | None): 线路信息
+            extra (dict): 额外参数
+
+        Returns:
+            Any: 执行结果
+        """
+        raise NotImplementedError("This set_record should be implemented by subclasses")
+
+    def set_proxy(self, proxy_str):
+        # type: (str | None) -> SimpleProvider
+        """
+        设置代理服务器
+
+        Set HTTPS proxy string.
+
+        Args:
+            proxy_str (str): 代理地址
+
+        Returns:
+            Self: 自身
+        """
+        self.proxy = proxy_str
+        return self
+
+    def _validate(self):
+        # type: () -> None
+        """
+        验证身份认证信息是否填写
+
+        Validate authentication credentials.
+        """
+        if not self.auth_id:
+            raise ValueError("id must be configured")
+        if not self.auth_token:
+            raise ValueError("token must be configured")
+        if not self.API:
+            raise ValueError("API endpoint must be defined in {}".format(self.__class__.__name__))
+
+    def _http(self, method, url, params=None, body=None, queries=None, headers=None):  # noqa: C901
+        # type: (str, str, dict[str,Any]|str|None, dict[str,Any]|str|None, dict[str,Any]|None, dict|None) -> Any
+        """
+        发送 HTTP/HTTPS 请求,自动根据 API/url 选择协议。
+
+        Args:
+            method (str): 请求方法,如 GET、POST
+            url (str): 请求路径
+            params (dict[str, Any] | None): 请求参数,自动处理 query string 或者body
+            body (dict[str, Any] | str | None): 请求体内容
+            queries (dict[str, Any] | None): 查询参数,自动处理为 URL 查询字符串
+            headers (dict): 头部,可选
+
+        Returns:
+            Any: 解析后的响应内容
+
+        Raises:
+            RuntimeError: 当响应状态码为400/401或5xx(服务器错误)时抛出异常
+        """
+        method = method.upper()
+
+        # 简化参数处理逻辑
+        query_params = queries or {}
+        if params:
+            if method in ("GET", "DELETE"):
+                if isinstance(params, dict):
+                    query_params.update(params)
+                else:
+                    # params是字符串,直接作为查询字符串
+                    url += ("&" if "?" in url else "?") + str(params)
+                    params = None
+            elif body is None:
+                body = params
+
+        # 构建查询字符串
+        if len(query_params) > 0:
+            url += ("&" if "?" in url else "?") + self._encode(query_params)
+
+        # 构建完整URL
+        if not url.startswith("http://") and not url.startswith("https://"):
+            if not url.startswith("/") and self.API.endswith("/"):
+                url = "/" + url
+            url = self.API + url
+
+        # 记录请求日志
+        self.logger.info("%s %s", method, self._mask_sensitive_data(url))
+
+        # 处理请求体
+        body_data, headers = None, headers or {}
+        if body:
+            if "content-type" not in headers:
+                headers["content-type"] = self.content_type
+            if isinstance(body, (str, bytes)):
+                body_data = body
+            elif self.content_type == TYPE_FORM:
+                body_data = self._encode(body)
+            else:
+                body_data = jsonencode(body)
+            self.logger.debug("body:\n%s", self._mask_sensitive_data(body_data))
+
+        # 处理headers
+        if self.accept and "accept" not in headers and "Accept" not in headers:
+            headers["accept"] = self.accept
+        if len(headers) > 2:
+            self.logger.debug("headers:\n%s", {k: self._mask_sensitive_data(v) for k, v in headers.items()})
+
+        response = send_http_request(
+            url=url,
+            method=method,
+            body=body_data,
+            headers=headers,
+            proxy=self.proxy,
+            max_redirects=5,
+            verify_ssl=self.verify_ssl,
+        )
+
+        # 处理响应
+        status_code = response.status
+        if not (200 <= status_code < 300):
+            self.logger.warning("response status: %s %s", status_code, response.reason)
+
+        res = response.body
+        # 针对客户端错误、认证/授权错误和服务器错误直接抛出异常
+        if status_code >= 500 or status_code in (400, 401, 403):
+            self.logger.error("HTTP error:\n%s", res)
+            if status_code == 400:
+                raise RuntimeError("请求参数错误 [400]: " + response.reason)
+            elif status_code == 401:
+                raise RuntimeError("认证失败 [401]: " + response.reason)
+            elif status_code == 403:
+                raise RuntimeError("权限不足 [403]: " + response.reason)
+            else:
+                raise RuntimeError("服务器错误 [{}]: {}".format(status_code, response.reason))
+
+        self.logger.debug("response:\n%s", res)
+        if not self.decode_response:
+            return res
+
+        try:
+            return jsondecode(res)
+        except Exception as e:
+            self.logger.error("fail to decode response: %s", e)
+        return res
+
+    @staticmethod
+    def _encode(params):
+        # type: (dict|list|str|bytes|None) -> str
+        """
+        编码参数为 URL 查询字符串
+
+        Args:
+            params (dict|list|str|bytes|None): 参数字典、列表或字符串
+        Returns:
+            str: 编码后的查询字符串
+        """
+        if not params:
+            return ""
+        elif isinstance(params, (str, bytes)):
+            return params  # type: ignore[return-value]
+        return urlencode(params, doseq=True)
+
+    @staticmethod
+    def _quote(data, safe="/"):
+        # type: (str, str) -> str
+        """
+        对字符串进行 URL 编码
+
+        Args:
+            data (str): 待编码字符串
+
+        Returns:
+            str: 编码后的字符串
+        """
+        return quote(data, safe=safe)
+
+    def _mask_sensitive_data(self, data):
+        # type: (str | bytes | None) -> str | bytes | None
+        """
+        对敏感数据进行打码处理,用于日志输出,支持URL编码的敏感信息
+
+        Args:
+            data (str | bytes | None): 需要处理的数据
+        Returns:
+            str | bytes | None: 打码后的字符串
+        """
+        if not data or not self.auth_token:
+            return data
+
+        # 生成打码后的token
+        token_masked = self.auth_token[:2] + "***" + self.auth_token[-2:] if len(self.auth_token) > 4 else "***"
+        token_encoded = quote(self.auth_token, safe="")
+
+        if isinstance(data, bytes):  # 处理字节数据
+            return data.replace(self.auth_token.encode(), token_masked.encode()).replace(
+                token_encoded.encode(), token_masked.encode()
+            )
+        if hasattr(data, "replace"):  # 处理字符串数据
+            return data.replace(self.auth_token, token_masked).replace(token_encoded, token_masked)
+        return data
+
+
+class BaseProvider(SimpleProvider):
+    """
+    标准DNS服务商接口的抽象基类
+
+    Abstract base class for all standard DNS provider APIs.
+    Subclasses must implement the abstract methods to support various providers.
+
+    * _query_zone_id()
+    * _query_record_id()
+    * _update_record()
+    * _create_record()
+    """
+
+    def set_record(self, domain, value, record_type="A", ttl=None, line=None, **extra):
+        # type: (str, str, str, str | int | None, str | None, **Any) -> bool
+        """
+        设置 DNS 记录(创建或更新)
+
+        Set or update DNS record.
+
+        Args:
+            domain (str): 完整域名
+            value (str): 新记录值
+            record_type (str): 记录类型
+            ttl (int | None): TTL 值,可选
+            line (str | None): 线路信息
+            extra (dict): 额外参数
+
+        Returns:
+            bool: 执行结果
+        """
+        domain = domain.lower()
+        self.logger.info("%s => %s(%s)", domain, value, record_type)
+
+        # 优化域名解析逻辑
+        sub, main = self._split_custom_domain(domain)
+        try:
+            if sub is not None:
+                # 使用自定义分隔符格式
+                zone_id = self.get_zone_id(main)
+            else:
+                # 自动分析域名
+                zone_id, sub, main = self._split_zone_and_sub(domain)
+
+            self.logger.info("sub: %s, main: %s(id=%s)", sub, main, zone_id)
+            if not zone_id or sub is None:
+                self.logger.critical("找不到 zone_id 或 subdomain: %s", domain)
+                return False
+
+            # 查询现有记录
+            record = self._query_record(zone_id, sub, main, record_type=record_type, line=line, extra=extra)
+
+            # 更新或创建记录
+            if record:
+                self.logger.info("Found existing record: %s", record)
+                return self._update_record(zone_id, record, value, record_type, ttl=ttl, line=line, extra=extra)
+            else:
+                self.logger.warning("No existing record found, creating new one")
+                return self._create_record(zone_id, sub, main, value, record_type, ttl=ttl, line=line, extra=extra)
+        except Exception as e:
+            self.logger.exception("Error setting record for %s: %s", domain, e)
+            return False
+
+    def get_zone_id(self, domain):
+        # type: (str) -> str | None
+        """
+        查询指定域名对应的 zone_id
+
+        Get zone_id for the domain.
+
+        Args:
+            domain (str): 主域名 / main name
+
+        Returns:
+            str | None: 区域 ID / Zone identifier
+        """
+        if domain in self._zone_map:
+            return self._zone_map[domain]
+        zone_id = self._query_zone_id(domain)
+        if zone_id:
+            self._zone_map[domain] = zone_id
+        return zone_id
+
+    @abstractmethod
+    def _query_zone_id(self, domain):
+        # type: (str) -> str | None
+        """
+        查询主域名的 zone ID
+
+        Args:
+            domain (str): 主域名
+
+        Returns:
+            str | None: Zone ID
+        """
+        return domain
+
+    @abstractmethod
+    def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
+        # type: (str, str, str, str, str | None, dict) -> Any
+        """
+        查询 DNS 记录 ID
+
+        Args:
+            zone_id (str): 区域 ID
+            subdomain (str): 子域名
+            main_domain (str): 主域名
+            record_type (str): 记录类型,例如 A、AAAA
+            line (str | None): 线路选项,可选
+            extra (dict): 额外参数
+        Returns:
+            Any | None: 记录
+        """
+        raise NotImplementedError("This _query_record should be implemented by subclasses")
+
+    @abstractmethod
+    def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl, line, extra):
+        # type: (str, str, str, str, str, int | str | None, str | None, dict) -> bool
+        """
+        创建新 DNS 记录
+
+        Args:
+            zone_id (str): 区域 ID
+            subdomain (str): 子域名
+            main_domain (str): 主域名
+            value (str): 记录值
+            record_type (str): 类型,如 A
+            ttl (int | None): TTL 可选
+            line (str | None): 线路选项
+            extra (dict | None): 额外字段
+
+        Returns:
+            Any: 操作结果
+        """
+        raise NotImplementedError("This _create_record should be implemented by subclasses")
+
+    @abstractmethod
+    def _update_record(self, zone_id, old_record, value, record_type, ttl, line, extra):
+        # type: (str, dict, str, str, int | str | None, str | None, dict) -> bool
+        """
+        更新已有 DNS 记录
+
+        Args:
+            zone_id (str): 区域 ID
+            old_record (dict): 旧记录信息
+            value (str): 新的记录值
+            record_type (str): 类型
+            ttl (int | None): TTL
+            line (str | None): 线路
+            extra (dict | None): 额外参数
+
+        Returns:
+            bool: 操作结果
+        """
+        raise NotImplementedError("This _update_record should be implemented by subclasses")
+
+    def _split_zone_and_sub(self, domain):
+        # type: (str) -> tuple[str | None, str | None, str ]
+        """
+        从完整域名拆分主域名和子域名
+
+        Args:
+            domain (str): 完整域名
+
+        Returns:
+            (zone_id, sub): 元组
+        """
+        domain_split = domain.split(".")
+        zone_id = None
+        index = 2
+        main = ""
+        while not zone_id and index <= len(domain_split):
+            main = ".".join(domain_split[-index:])
+            zone_id = self.get_zone_id(main)
+            index += 1
+        if zone_id:
+            sub = ".".join(domain_split[: -index + 1]) or "@"
+            self.logger.debug("zone_id: %s, sub: %s", zone_id, sub)
+            return zone_id, sub, main
+        return None, None, main
+
+    @staticmethod
+    def _split_custom_domain(domain):
+        # type: (str) -> tuple[str | None, str]
+        """
+        拆分支持 ~ 或 + 的自定义格式域名为 (子域, 主域)
+
+        如 sub~example.com => ('sub', 'example.com')
+
+        Returns:
+            (sub, main): 子域 + 主域
+        """
+        for sep in ("~", "+"):
+            if sep in domain:
+                sub, main = domain.split(sep, 1)
+                return sub, main
+        return None, domain
+
+    @staticmethod
+    def _join_domain(sub, main):
+        # type: (str | None, str) -> str
+        """
+        合并子域名和主域名为完整域名
+
+        Args:
+            sub (str | None): 子域名
+            main (str): 主域名
+
+        Returns:
+            str: 完整域名
+        """
+        sub = sub and sub.strip(".").strip().lower()
+        main = main and main.strip(".").strip().lower()
+        if not sub or sub == "@":
+            if not main:
+                raise ValueError("Both sub and main cannot be empty")
+            return main
+        if not main:
+            return sub
+        return "{}.{}".format(sub, main)

+ 147 - 175
ddns/provider/alidns.py

@@ -2,182 +2,154 @@
 """
 AliDNS API
 阿里DNS解析操作库
-https://help.aliyun.com/document_detail/29739.html
-@author: New Future
+@author: NewFuture
 """
 
-from hashlib import sha1
+from ._base import TYPE_FORM, BaseProvider
+from hashlib import sha256
 from hmac import new as hmac
-from uuid import uuid4
-from base64 import b64encode
-from json import loads as jsondecode
-from logging import debug, info, warning
-from datetime import datetime
-
-try:  # python 3
-    from http.client import HTTPSConnection
-    from urllib.parse import urlencode, quote_plus, quote
-except ImportError:  # python 2
-    from httplib import HTTPSConnection
-    from urllib import urlencode, quote_plus, quote
-
-__author__ = 'New Future'
-# __all__ = ["request", "ID", "TOKEN", "PROXY"]
-
-
-class Config:
-    ID = "id"
-    TOKEN = "TOKEN"
-    PROXY = None  # 代理设置
-    TTL = None
-
-
-class API:
-    # API 配置
-    SITE = "alidns.aliyuncs.com"  # API endpoint
-    METHOD = "POST"  # 请求方法
-
-
-def signature(params):
-    """
-    计算签名,返回签名后的查询参数
-    """
-    params.update({
-        'Format': 'json',
-        'Version': '2015-01-09',
-        'AccessKeyId': Config.ID,
-        'Timestamp': datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
-        'SignatureMethod': 'HMAC-SHA1',
-        'SignatureNonce': uuid4(),
-        'SignatureVersion': "1.0",
-    })
-    query = urlencode(sorted(params.items()))
-    query = query.replace('+', '%20')
-    debug(query)
-    sign = API.METHOD + "&" + quote_plus("/") + "&" + quote(query, safe='')
-    debug("signString: %s", sign)
-
-    sign = hmac((Config.TOKEN + "&").encode('utf-8'),
-                sign.encode('utf-8'), sha1).digest()
-    sign = b64encode(sign).strip()
-    params["Signature"] = sign
-    return params
-
-
-def request(param=None, **params):
-    """
-    发送请求数据
-    """
-    if param:
-        params.update(param)
-    params = dict((k, params[k]) for k in params if params[k] is not None)
-    params = signature(params)
-    info("%s: %s", API.SITE, params)
-
-    if Config.PROXY:
-        conn = HTTPSConnection(Config.PROXY)
-        conn.set_tunnel(API.SITE, 443)
-    else:
-        conn = HTTPSConnection(API.SITE)
-    conn.request(API.METHOD, '/', urlencode(params),
-                 {"Content-type": "application/x-www-form-urlencoded"})
-    response = conn.getresponse()
-    data = response.read().decode('utf8')
-    conn.close()
-
-    if response.status < 200 or response.status >= 300:
-        warning('%s : error[%d]: %s', params['Action'], response.status, data)
-        raise Exception(data)
-    else:
-        data = jsondecode(data)
-        debug('%s : result:%s', params['Action'], data)
-        return data
-
-
-def get_domain_info(domain):
-    """
-    切割域名获取主域名和对应ID
-    https://help.aliyun.com/document_detail/29755.html
-    http://alidns.aliyuncs.com/?Action=GetMainDomainName&InputString=www.example.com
-    """
-    res = request(Action="GetMainDomainName", InputString=domain)
-    sub, main = res.get('RR'), res.get('DomainName')
-    return sub, main
-
-
-def get_records(domain, **conditions):
-    """
-        获取记录ID
-        返回满足条件的所有记录[]
-        https://help.aliyun.com/document_detail/29776.html
-        TODO 大于500翻页
-    """
-    if not hasattr(get_records, "records"):
-        get_records.records = {}  # "静态变量"存储已查询过的id
-        get_records.keys = ("RecordId", "RR", "Type", "Line",
-                            "Locked", "Status", "Priority", "Value")
-
-    if domain not in get_records.records:
-        get_records.records[domain] = {}
-        data = request(Action="DescribeDomainRecords",
-                       DomainName=domain, PageSize=500)
-        if data:
-            for record in data.get('DomainRecords').get('Record'):
-                get_records.records[domain][record["RecordId"]] = {
-                    k: v for (k, v) in record.items() if k in get_records.keys}
-    records = {}
-    for (rid, record) in get_records.records[domain].items():
-        for (k, value) in conditions.items():
-            if record.get(k) != value:
-                break
-        else:  # for else push
-            records[rid] = record
-    return records
-
-
-def update_record(domain, value, record_type='A'):
-    """
-        更新记录
-        update
-        https://help.aliyun.com/document_detail/29774.html
-        add
-        https://help.aliyun.com/document_detail/29772.html?
-    """
-    debug(">>>>>%s(%s)", domain, record_type)
-    sub, main = get_domain_info(domain)
-    if not sub:
-        raise Exception("invalid domain: [ %s ] " % domain)
-
-    records = get_records(main, RR=sub, Type=record_type)
-    result = {}
-
-    if records:
-        for (rid, record) in records.items():
-            if record["Value"] != value:
-                debug(sub, record)
-                res = request(Action="UpdateDomainRecord", RecordId=rid,
-                              Value=value, RR=sub, Type=record_type, TTL=Config.TTL)
-                if res:
-                    # update records
-                    get_records.records[main][rid]["Value"] = value
-                    result[rid] = res
-                else:
-                    result[rid] = "update fail!\n" + str(res)
-            else:
-                result[rid] = domain
-    else:  # https://help.aliyun.com/document_detail/29772.html
-        res = request(Action="AddDomainRecord", DomainName=main,
-                      Value=value, RR=sub, Type=record_type, TTL=Config.TTL)
-        if res:
-            # update records INFO
-            rid = res.get('RecordId')
-            get_records.records[main][rid] = {
-                'Value': value,
-                "RecordId": rid,
-                "RR": sub,
-                "Type": record_type
-            }
-            result = res
+from time import strftime, gmtime, time
+
+
+class AlidnsProvider(BaseProvider):
+    API = "https://alidns.aliyuncs.com"
+    content_type = TYPE_FORM  # 阿里云DNS API使用表单格式
+
+    api_version = "2015-01-09"  # API版本,v3签名需要
+
+    def _signature_v3(self, method, path, headers, query="", body_hash=""):
+        # type: (str, str, dict, str, str) -> str
+        """阿里云API v3签名算法 https://help.aliyun.com/zh/sdk/product-overview/v3-request-structure-and-signature"""
+        # 构造规范化头部
+        headers_to_sign = {k.lower(): str(v).strip() for k, v in headers.items()}
+
+        # 按字母顺序排序并构造
+        signed_headers_list = sorted(headers_to_sign.keys())
+        canonical_headers = "".join("{}:{}\n".format(key, headers_to_sign[key]) for key in signed_headers_list)
+        signed_headers = ";".join(signed_headers_list)
+
+        # 构造规范化请求
+        canonical_request = "\n".join([method, path, query, canonical_headers, signed_headers, body_hash])
+
+        # 5. 构造待签名字符串
+        algorithm = "ACS3-HMAC-SHA256"
+        hashed_canonical_request = sha256(canonical_request.encode("utf-8")).hexdigest()
+        string_to_sign = "\n".join([algorithm, hashed_canonical_request])
+        self.logger.debug("String to sign: %s", string_to_sign)
+
+        # 6. 计算签名
+        signature = hmac(self.auth_token.encode("utf-8"), string_to_sign.encode("utf-8"), sha256).hexdigest()
+
+        # 7. 构造Authorization头
+        authorization = "{} Credential={},SignedHeaders={},Signature={}".format(
+            algorithm, self.auth_id, signed_headers, signature
+        )
+        return authorization
+
+    def _request(self, action, **params):
+        # type: (str, **(str | int | bytes | bool | None)) -> dict
+        params = {k: v for k, v in params.items() if v is not None}
+        # 从API URL中提取host
+        host = self.API.replace("https://", "").replace("http://", "").strip("/")
+        body_content = self._encode(params) if len(params) > 0 else ""
+        content_hash = sha256(body_content.encode("utf-8")).hexdigest()
+        # 构造请求头部
+        headers = {
+            "host": host,
+            "content-type": self.content_type,
+            "x-acs-action": action,
+            "x-acs-content-sha256": content_hash,
+            "x-acs-date": strftime("%Y-%m-%dT%H:%M:%SZ", gmtime()),
+            "x-acs-signature-nonce": str(hash(time()))[2:],
+            "x-acs-version": self.api_version,
+        }
+
+        # 生成Authorization头
+        authorization = self._signature_v3("POST", "/", headers, body_hash=content_hash)
+        headers["Authorization"] = authorization
+        # 对于v3签名的RPC API,参数在request body中
+        return self._http("POST", "/", body=body_content, headers=headers)
+
+    def _split_zone_and_sub(self, domain):
+        # type: (str) -> tuple[str | None, str | None, str]
+        """
+        AliDNS 支持直接查询主域名和RR,无需循环查询。
+        返回没有DomainId,用DomainName代替
+        https://help.aliyun.com/zh/dns/api-alidns-2015-01-09-getmaindomainname
+        """
+        res = self._request("GetMainDomainName", InputString=domain)
+        sub, main = res.get("RR"), res.get("DomainName")
+        return (main, sub, main or domain)
+
+    def _query_zone_id(self, domain):
+        """调用_split_zone_and_sub可直接获取,无需调用_query_zone_id"""
+        raise NotImplementedError("_split_zone_and_sub is used to get zone_id")
+
+    def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
+        """https://help.aliyun.com/zh/dns/api-alidns-2015-01-09-describesubdomainrecords"""
+        sub = self._join_domain(subdomain, main_domain)
+        data = self._request(
+            "DescribeSubDomainRecords",
+            SubDomain=sub,  # aliyun API要求SubDomain为完整域名
+            DomainName=main_domain,
+            Type=record_type,
+            Line=line,
+            PageSize=500,
+            Lang=extra.get("Lang"),  # 默认中文
+            Status=extra.get("Status"),  # 默认全部状态
+        )
+        records = data.get("DomainRecords", {}).get("Record", [])
+        if not records:
+            self.logger.warning(
+                "No records found for [%s] with %s <%s> (line: %s)", zone_id, subdomain, record_type, line
+            )
+        elif not isinstance(records, list):
+            self.logger.error("Invalid records format: %s", records)
         else:
-            result = domain + " created fail!"
-    return result
+            return next((r for r in records), None)
+        return None
+
+    def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl, line, extra):
+        """https://help.aliyun.com/zh/dns/api-alidns-2015-01-09-adddomainrecord"""
+        data = self._request(
+            "AddDomainRecord",
+            DomainName=main_domain,
+            RR=subdomain,
+            Value=value,
+            Type=record_type,
+            TTL=ttl,
+            Line=line,
+            **extra
+        )
+        if data and data.get("RecordId"):
+            self.logger.info("Record created: %s", data)
+            return True
+        self.logger.error("Failed to create record: %s", data)
+        return False
+
+    def _update_record(self, zone_id, old_record, value, record_type, ttl, line, extra):
+        """https://help.aliyun.com/zh/dns/api-alidns-2015-01-09-updatedomainrecord"""
+        # 阿里云DNS update新旧值不能一样,先判断是否发生变化
+        if (
+            old_record.get("Value") == value
+            and old_record.get("Type") == record_type
+            and (not ttl or old_record.get("TTL") == ttl)
+        ):
+            domain = self._join_domain(old_record.get("RR"), old_record.get("DomainName"))
+            self.logger.warning("No changes detected, skipping update for record: %s", domain)
+            return True
+        data = self._request(
+            "UpdateDomainRecord",
+            RecordId=old_record.get("RecordId"),
+            Value=value,
+            RR=old_record.get("RR"),
+            Type=record_type,
+            TTL=ttl,
+            Line=line or old_record.get("Line"),
+            **extra
+        )
+        if data and data.get("RecordId"):
+            self.logger.info("Record updated: %s", data)
+            return True
+        self.logger.error("Failed to update record: %s", data)
+        return False

+ 67 - 105
ddns/provider/callback.py

@@ -3,115 +3,77 @@
 Custom Callback API
 自定义回调接口解析操作库
 
-@author: 老周部落
+@author: 老周部落, NewFuture
 """
-
-from json import loads as jsondecode
-from logging import debug, info, warning
+from ._base import TYPE_JSON, SimpleProvider
 from time import time
-
-try:  # python 3
-    from http.client import HTTPSConnection, HTTPConnection
-    from urllib.parse import urlencode, urlparse, parse_qsl
-except ImportError:  # python 2
-    from httplib import HTTPSConnection, HTTPConnection
-    from urlparse import urlparse, parse_qsl
-    from urllib import urlencode
-
-__author__ = '老周部落'
-
-
-class Config:
-    ID = None  # 自定义回调 URL
-    TOKEN = None  # 使用 JSON 编码的 POST 参数
-    PROXY = None   # 代理设置
-    TTL = None
-
-
-def request(method, action, param=None, **params):
-    """
-    发送请求数据
-    """
-    if param:
-        params.update(param)
-
-    URLObj = urlparse(Config.ID)
-    params = dict((k, params[k]) for k in params if params[k] is not None)
-    info("%s/%s : %s", URLObj.netloc, action, params)
-
-    if Config.PROXY:
-        if URLObj.netloc == "http":
-            conn = HTTPConnection(Config.PROXY)
-        else:
-            conn = HTTPSConnection(Config.PROXY)
-        conn.set_tunnel(URLObj.netloc, URLObj.port)
-    else:
-        if URLObj.netloc == "http":
-            conn = HTTPConnection(URLObj.netloc, URLObj.port)
-        else:
-            conn = HTTPSConnection(URLObj.netloc, URLObj.port)
-
-    headers = {}
-
-    if method == "GET":
-        if params:
-            action += '?' + urlencode(params)
-        params = ""
-    else:
-        headers["Content-Type"] = "application/x-www-form-urlencoded"
-
-    params = urlencode(params)
-
-    conn.request(method, action, params, headers)
-    response = conn.getresponse()
-    res = response.read().decode('utf8')
-    conn.close()
-    if response.status < 200 or response.status >= 300:
-        warning('%s : error[%d]:%s', action, response.status, res)
-        raise Exception(res)
-    else:
-        debug('%s : result:%s', action, res)
-        return res
-
-
-def replace_params(domain, record_type, ip, params):
-    """
-    替换定义常量为实际值
-    """
-    dict = {"__DOMAIN__": domain, "__RECORDTYPE__": record_type,
-            "__TTL__": Config.TTL, "__TIMESTAMP__": time(), "__IP__": ip}
-    for key, value in params.items():
-        if dict.get(value):
-            params[key] = dict.get(value)
-    return params
+from json import loads as jsondecode
 
 
-def update_record(domain, value, record_type="A"):
+class CallbackProvider(SimpleProvider):
     """
-    更新记录
+    通用自定义回调 Provider,支持 GET/POST 任意接口。
+    Generic custom callback provider, supports GET/POST arbitrary API.
     """
-    info(">>>>>%s(%s)", domain, record_type)
-
-    result = {}
-
-    if not Config.TOKEN:  # 此处使用 TOKEN 参数透传 POST 参数所用的 JSON
-        method = "GET"
-        URLObj = urlparse(Config.ID)
-        path = URLObj.path
-        query = dict(parse_qsl(URLObj.query))
-        params = replace_params(domain, record_type, value, query)
-    else:
-        method = "POST"
-        URLObj = urlparse(Config.ID)
-        path = URLObj.path
-        params = replace_params(domain, record_type,
-                                value, jsondecode(Config.TOKEN))
-
-    res = request(method, path, params)
-
-    if res:
-        result = "Callback Request Success!\n" + res
-    else:
-        result = "Callback Request Fail!\n"
 
-    return result
+    API = ""  # CallbackProvider uses auth_id as URL, no fixed API endpoint
+    content_type = TYPE_JSON
+    decode_response = False  # Callback response is not JSON, it's a custom response
+
+    def set_record(self, domain, value, record_type="A", ttl=None, line=None, **extra):
+        """
+        发送自定义回调请求,支持 GET/POST
+        Send custom callback request, support GET/POST
+        """
+        self.logger.info("%s => %s(%s)", domain, value, record_type)
+        url = self.auth_id  # 直接用 auth_id 作为 url
+        token = self.auth_token  # auth_token 作为 POST 参数
+        headers = {"User-Agent": "DDNS/{0} ([email protected])".format(self.version)}
+        extra.update(
+            {
+                "__DOMAIN__": domain,
+                "__RECORDTYPE__": record_type,
+                "__TTL__": ttl,
+                "__IP__": value,
+                "__TIMESTAMP__": time(),
+                "__LINE__": line,
+            }
+        )
+        url = self._replace_vars(url, extra)
+        method, params = "GET", None
+        if token:
+            # 如果有 token,使用 POST 方法
+            method = "POST"
+            # POST 方式,token 作为 POST 参数
+            params = token if isinstance(token, dict) else jsondecode(token)
+            for k, v in params.items():
+                if hasattr(v, "replace"):  # 判断是否支持字符串替换, 兼容py2,py3
+                    params[k] = self._replace_vars(v, extra)
+
+        try:
+            res = self._http(method, url, body=params, headers=headers)
+            if res is not None:
+                self.logger.info("Callback result: %s", res)
+                return True
+            else:
+                self.logger.warning("Callback received empty response.")
+        except Exception as e:
+            self.logger.error("Callback failed: %s", e)
+        return False
+
+    def _replace_vars(self, string, mapping):
+        # type: (str, dict) -> str
+        """
+        替换字符串中的变量为实际值
+        Replace variables in string with actual values
+        """
+        for k, v in mapping.items():
+            string = string.replace(k, str(v))
+        return string
+
+    def _validate(self):
+        # CallbackProvider uses auth_id as URL, not as regular ID
+        if not self.auth_id or "://" not in self.auth_id:
+            self.logger.critical("callback ID 参数[%s] 必须是有效的URL", self.auth_id)
+            raise ValueError("id must be configured with URL")
+        # CallbackProvider doesn't need auth_token validation (it can be empty)

+ 95 - 155
ddns/provider/cloudflare.py

@@ -1,163 +1,103 @@
 # coding=utf-8
 """
 CloudFlare API
-CloudFlare 接口解析操作库
-https://api.cloudflare.com/#dns-records-for-a-zone-properties
-@author: TongYifan
+@author: TongYifan, NewFuture
 """
 
-from json import loads as jsondecode, dumps as jsonencode
-from logging import debug, info, warning
-
-try:  # python 3
-    from http.client import HTTPSConnection
-    from urllib.parse import urlencode
-except ImportError:  # python 2
-    from httplib import HTTPSConnection
-    from urllib import urlencode
-
-__author__ = 'TongYifan'
-
-
-class Config:
-    ID = "AUTH EMAIL"  # CloudFlare 验证的是用户Email,等同于其他平台的userID
-    TOKEN = "API KEY"
-    PROXY = None  # 代理设置
-    TTL = None
-
-
-class API:
-    # API 配置
-    SITE = "api.cloudflare.com"  # API endpoint
-
-
-def request(method, action, param=None, **params):
-    """
-        发送请求数据
-    """
-    if param:
-        params.update(param)
-
-    params = dict((k, params[k]) for k in params if params[k] is not None)
-    info("%s/%s : %s", API.SITE, action, params)
-    if Config.PROXY:
-        conn = HTTPSConnection(Config.PROXY)
-        conn.set_tunnel(API.SITE, 443)
-    else:
-        conn = HTTPSConnection(API.SITE)
-
-    if method in ['PUT', 'POST', 'PATCH']:
-        # 从public_v(4,6)获取的IP是bytes类型,在json.dumps时会报TypeError
-        params['content'] = str(params.get('content'))
-        params = jsonencode(params)
-    else:  # (GET, DELETE) where DELETE doesn't require params in Cloudflare
-        if params:
-            action += '?' + urlencode(params)
-        params = None
-    if not Config.ID:
-        headers = {"Content-type": "application/json",
-                   "Authorization": "Bearer " + Config.TOKEN}
-    else:
-        headers = {"Content-type": "application/json",
-                   "X-Auth-Email": Config.ID, "X-Auth-Key": Config.TOKEN}
-    conn.request(method, '/client/v4/zones' + action, params, headers)
-    response = conn.getresponse()
-    res = response.read().decode('utf8')
-    conn.close()
-    if response.status < 200 or response.status >= 300:
-        warning('%s : error[%d]:%s', action, response.status, res)
-        raise Exception(res)
-    else:
-        data = jsondecode(res)
-        debug('%s : result:%s', action, data)
-        if not data:
-            raise Exception("Empty Response")
-        elif data.get('success'):
-            return data.get('result', [{}])
+from ._base import BaseProvider, TYPE_JSON
+
+
+class CloudflareProvider(BaseProvider):
+    API = "https://api.cloudflare.com"
+    content_type = TYPE_JSON
+
+    def _validate(self):
+        self.logger.warning(
+            "Cloudflare provider 缺少充分的真实环境测试,如遇问题请及时在 GitHub Issues 中反馈: %s",
+            "https://github.com/NewFuture/DDNS/issues",
+        )
+        if not self.auth_token:
+            raise ValueError("token must be configured")
+        if self.auth_id:
+            # must be email for Cloudflare API v4
+            if "@" not in self.auth_id:
+                self.logger.critical("ID 必须为空或有效的邮箱地址")
+                raise ValueError("ID must be a valid email or Empty for Cloudflare API v4")
+
+    def _request(self, method, action, **params):
+        """发送请求数据"""
+        headers = {}
+        if self.auth_id:
+            headers["X-Auth-Email"] = self.auth_id
+            headers["X-Auth-Key"] = self.auth_token
         else:
-            raise Exception(data.get('errors', [{}]))
-
+            headers["Authorization"] = "Bearer " + self.auth_token
 
-def get_zone_id(domain):
-    """
-        切割域名获取主域名ID(Zone_ID)
-        https://api.cloudflare.com/#zone-list-zones
-    """
-    zoneid = None
-    domain_slice = domain.split('.')
-    index = 2
-    # ddns.example.com => example.com; ddns.example.eu.org => example.eu.org
-    while (not zoneid) and (index <= len(domain_slice)):
-        zones = request('GET', '', name='.'.join(domain_slice[-index:]))
-        zone = next((z for z in zones if domain.endswith(z.get('name'))), None)
-        zoneid = zone and zone['id']
-        index += 1
-    return zoneid
-
-
-def get_records(zoneid, **conditions):
-    """
-           获取记录ID
-           返回满足条件的所有记录[]
-           TODO 大于100翻页
-    """
-    cache_key = zoneid + "_" + \
-        conditions.get('name', "") + "_" + conditions.get('type', "")
-    if not hasattr(get_records, 'records'):
-        get_records.records = {}  # "静态变量"存储已查询过的id
-        get_records.keys = ('id', 'type', 'name', 'content', 'proxied', 'ttl')
-
-    if zoneid not in get_records.records:
-        get_records.records[cache_key] = {}
-        data = request('GET', '/' + zoneid + '/dns_records',
-                       per_page=100, **conditions)
-        if data:
-            for record in data:
-                get_records.records[cache_key][record['id']] = {
-                    k: v for (k, v) in record.items() if k in get_records.keys}
-
-    records = {}
-    for (zid, record) in get_records.records[cache_key].items():
-        for (k, value) in conditions.items():
-            if record.get(k) != value:
-                break
-        else:  # for else push
-            records[zid] = record
-    return records
-
-
-def update_record(domain, value, record_type="A"):
-    """
-    更新记录
-    """
-    info(">>>>>%s(%s)", domain, record_type)
-    zoneid = get_zone_id(domain)
-    if not zoneid:
-        raise Exception("invalid domain: [ %s ] " % domain)
-
-    records = get_records(zoneid, name=domain, type=record_type)
-    cache_key = zoneid + "_" + domain + "_" + record_type
-    result = {}
-    if records:  # update
-        # https://api.cloudflare.com/#dns-records-for-a-zone-update-dns-record
-        for (rid, record) in records.items():
-            if record['content'] != value:
-                res = request('PUT', '/' + zoneid + '/dns_records/' + record['id'],
-                              type=record_type, content=value, name=domain, proxied=record['proxied'], ttl=Config.TTL)
-                if res:
-                    get_records.records[cache_key][rid]['content'] = value
-                    result[rid] = res.get("name")
-                else:
-                    result[rid] = "Update fail!\n" + str(res)
-            else:
-                result[rid] = domain
-    else:  # create
-        # https://api.cloudflare.com/#dns-records-for-a-zone-create-dns-record
-        res = request('POST', '/' + zoneid + '/dns_records',
-                      type=record_type, name=domain, content=value, proxied=False, ttl=Config.TTL)
-        if res:
-            get_records.records[cache_key][res['id']] = res
-            result = res
+        params = {k: v for k, v in params.items() if v is not None}  # 过滤掉None参数
+        data = self._http(method, "/client/v4/zones" + action, headers=headers, params=params)
+        if data and data.get("success"):
+            return data.get("result")  # 返回结果或原始数据
         else:
-            result = domain + " created fail!"
-    return result
+            self.logger.warning("Cloudflare API error: %s", data.get("errors", "Unknown error"))
+        return data
+
+    def _query_zone_id(self, domain):
+        """https://developers.cloudflare.com/api/resources/zones/methods/list/"""
+        params = {"name.exact": domain, "per_page": 50}
+        zones = self._request("GET", "", **params)
+        zone = next((z for z in zones if domain == z.get("name", "")), None)
+        self.logger.debug("Queried zone: %s", zone)
+        if zone:
+            return zone["id"]
+        return None
+
+    def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
+        # type: (str, str, str, str, str | None, dict) -> dict | None
+        """https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/list/"""
+        # cloudflare的域名查询需要完整域名
+        name = self._join_domain(subdomain, main_domain)
+        query = {"name.exact": name}  # type: dict[str, str|None]
+        if extra:
+            query["proxied"] = extra.get("proxied", None)  # 代理状态
+        data = self._request("GET", "/{}/dns_records".format(zone_id), type=record_type, per_page=10000, **query)
+        record = next((r for r in data if r.get("name") == name and r.get("type") == record_type), None)
+        self.logger.debug("Record queried: %s", record)
+        if record:
+            return record
+        self.logger.warning("Failed to query record: %s", data)
+        return None
+
+    def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl, line, extra):
+        # type: (str, str, str, str, str, int | str | None, str | None, dict ) -> bool
+        """https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/"""
+        name = self._join_domain(subdomain, main_domain)
+        extra["comment"] = extra.get("comment", self.remark)  # 添加注释
+        data = self._request(
+            "POST", "/{}/dns_records".format(zone_id), name=name, type=record_type, content=value, ttl=ttl, **extra
+        )
+        if data:
+            self.logger.info("Record created: %s", data)
+            return True
+        self.logger.error("Failed to create record: %s", data)
+        return False
+
+    def _update_record(self, zone_id, old_record, value, record_type, ttl, line, extra):
+        # type: (str, dict, str, str, int | str | None, str | None, dict) -> bool
+        """https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/"""
+        extra["comment"] = extra.get("comment", self.remark)  # 注释
+        extra["proxied"] = old_record.get("proxied", extra.get("proxied"))  # 保持原有的代理状态
+        extra["tags"] = old_record.get("tags", extra.get("tags"))  # 保持原有的标签
+        extra["settings"] = old_record.get("settings", extra.get("settings"))  # 保持原有的设置
+        data = self._request(
+            "PUT",
+            "/{}/dns_records/{}".format(zone_id, old_record["id"]),
+            type=record_type,
+            name=old_record.get("name"),
+            content=value,
+            ttl=ttl,
+            **extra
+        )
+        self.logger.debug("Record updated: %s", data)
+        if data:
+            return True
+        return False

+ 20 - 0
ddns/provider/debug.py

@@ -0,0 +1,20 @@
+# coding=utf-8
+"""
+DebugProvider
+仅打印出 IP 地址,不进行任何实际 DNS 更新。
+"""
+
+from ._base import SimpleProvider
+
+
+class DebugProvider(SimpleProvider):
+
+    def _validate(self):
+        """无需任何验证"""
+        pass
+
+    def set_record(self, domain, value, record_type="A", ttl=None, line=None, **extra):
+        self.logger.debug("DebugProvider: %s(%s) => %s", domain, record_type, value)
+        ip_type = "IPv4" if record_type == "A" else "IPv6" if record_type == "AAAA" else record_type
+        print("[{}] {}".format(ip_type, value))
+        return True

+ 91 - 169
ddns/provider/dnscom.py

@@ -1,180 +1,102 @@
 # coding=utf-8
 """
-DNSCOM API
-DNS.COM 接口解析操作库
-http://open.dns.com/
-@author: Bigjin
-@mailto: [email protected]
+DNSCOM/51dns API 接口解析操作库
+www.51dns.com (原dns.com)
+@author: Bigjin<[email protected]>, NewFuture
 """
 
+from ._base import BaseProvider, TYPE_FORM
 from hashlib import md5
-from json import loads as jsondecode
-from logging import debug, info, warning
-from time import mktime
-from datetime import datetime
+from time import time
 
-try:  # python 3
-    from http.client import HTTPSConnection
-    from urllib.parse import urlencode
-except ImportError:  # python 2
-    from httplib import HTTPSConnection
-    from urllib import urlencode
 
-__author__ = 'Bigjin'
-# __all__ = ["request", "ID", "TOKEN", "PROXY"]
-
-
-class Config:
-    ID = "id"
-    TOKEN = "TOKEN"
-    PROXY = None  # 代理设置
-    TTL = None
-
-
-class API:
-    # API 配置
-    SITE = "www.dns.com"  # API endpoint
-    METHOD = "POST"  # 请求方法
-
-
-def signature(params):
+class DnscomProvider(BaseProvider):
     """
-    计算签名,返回签名后的查询参数
+    DNSCOM/51dns API Provider
+    https://www.51dns.com/document/api/index.html
     """
-    params.update({
-        'apiKey': Config.ID,
-        'timestamp': mktime(datetime.now().timetuple()),
-    })
-    query = urlencode(sorted(params.items()))
-    debug(query)
-    sign = query
-    debug("signString: %s", sign)
-
-    sign = md5((sign + Config.TOKEN).encode('utf-8')).hexdigest()
-    params["hash"] = sign
-
-    return params
-
 
-def request(action, param=None, **params):
-    """
-    发送请求数据
-    """
-    if param:
-        params.update(param)
-    params = dict((k, params[k]) for k in params if params[k] is not None)
-    params = signature(params)
-    info("%s/api/%s/ : params:%s", API.SITE, action, params)
-
-    if Config.PROXY:
-        conn = HTTPSConnection(Config.PROXY)
-        conn.set_tunnel(API.SITE, 443)
-    else:
-        conn = HTTPSConnection(API.SITE)
-
-    conn.request(API.METHOD, '/api/' + action + '/', urlencode(params),
-                 {"Content-type": "application/x-www-form-urlencoded"})
-    response = conn.getresponse()
-    result = response.read().decode('utf8')
-    conn.close()
-
-    if response.status < 200 or response.status >= 300:
-        warning('%s : error[%d]:%s', action, response.status, result)
-        raise Exception(result)
-    else:
-        data = jsondecode(result)
-        debug('%s : result:%s', action, data)
-        if data.get('code') != 0:
-            raise Exception("api error:", data.get('message'))
-        data = data.get('data')
-        if data is None:
-            raise Exception('response data is none')
-        return data
-
-
-def get_domain_info(domain):
-    """
-    切割域名获取主域名和对应ID
-    """
-    if len(domain.split('.')) > 2:
-        domains = domain.split('.', 1)
-        sub = domains[0]
-        main = domains[1]
-    else:
-        sub = ''  # 接口有bug 不能传 @ * 作为主机头,但是如果为空,默认为 @
-        main = domain
-
-    res = request("domain/getsingle", domainID=main)
-    domain_id = res.get('domainID')
-    return sub, main, domain_id
-
-
-def get_records(domain, domain_id, **conditions):
-    """
-        获取记录ID
-        返回满足条件的所有记录[]
-        TODO 大于500翻页
-    """
-    if not hasattr(get_records, "records"):
-        get_records.records = {}  # "静态变量"存储已查询过的id
-        get_records.keys = ("recordID", "record", "type", "viewID",
-                            "TTL", "state", "value")
-
-    if domain not in get_records.records:
-        get_records.records[domain] = {}
-        data = request("record/list",
-                       domainID=domain_id, pageSize=500)
-        if data.get('data'):
-            for record in data.get('data'):
-                get_records.records[domain][record["recordID"]] = {
-                    k: v for (k, v) in record.items() if k in get_records.keys}
-    records = {}
-    for (rid, record) in get_records.records[domain].items():
-        for (k, value) in conditions.items():
-            if record.get(k) != value:
-                break
-        else:  # for else push
-            records[rid] = record
-    return records
-
-
-def update_record(domain, value, record_type='A'):
-    """
-        更新记录
-    """
-    info(">>>>>%s(%s)", domain, record_type)
-    sub, main, domain_id = get_domain_info(domain)
-
-    records = get_records(main, domain_id, record=sub, type=record_type)
-    result = {}
-
-    if records:
-        for (rid, record) in records.items():
-            if record["value"] != value:
-                debug(sub, record)
-                res = request("record/modify", domainID=domain_id,
-                              recordID=rid, newvalue=value, newTTL=Config.TTL)
-                if res:
-                    # update records
-                    get_records.records[main][rid]["value"] = value
-                    result[rid] = res
-                else:
-                    result[rid] = "update fail!\n" + str(res)
-            else:
-                result[rid] = domain
-    else:
-        res = request("record/create", domainID=domain_id,
-                      value=value, host=sub, type=record_type, TTL=Config.TTL)
-        if res:
-            # update records INFO
-            rid = res.get('recordID')
-            get_records.records[main][rid] = {
-                'value': value,
-                "recordID": rid,
-                "record": sub,
-                "type": record_type
+    API = "https://www.51dns.com"
+    content_type = TYPE_FORM
+
+    def _validate(self):
+        self.logger.warning(
+            "DNS.COM provider 缺少充分的真实环境测试,如遇问题请及时在 GitHub Issues 中反馈: %s",
+            "https://github.com/NewFuture/DDNS/issues",
+        )
+        super(DnscomProvider, self)._validate()
+
+    def _signature(self, params):
+        """https://www.51dns.com/document/api/70/72.html"""
+        params = {k: v for k, v in params.items() if v is not None}
+        params.update(
+            {
+                "apiKey": self.auth_id,
+                "timestamp": time(),  # 时间戳
             }
-            result = res
-        else:
-            result = domain + " created fail!"
-    return result
+        )
+        query = self._encode(sorted(params.items()))
+        sign = md5((query + self.auth_token).encode("utf-8")).hexdigest()
+        params["hash"] = sign
+        return params
+
+    def _request(self, action, **params):
+        params = self._signature(params)
+        data = self._http("POST", "/api/{}/".format(action), body=params)
+        if data is None or not isinstance(data, dict):
+            raise Exception("response data is none")
+        if data.get("code", 0) != 0:
+            raise Exception("api error: " + str(data.get("message")))
+        return data.get("data")
+
+    def _query_zone_id(self, domain):
+        """https://www.51dns.com/document/api/74/31.html"""
+        res = self._request("domain/getsingle", domainID=domain)
+        self.logger.debug("Queried domain: %s", res)
+        if res:
+            return res.get("domainID")
+        return None
+
+    def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
+        """https://www.51dns.com/document/api/4/47.html"""
+        records = self._request("record/list", domainID=zone_id, host=subdomain, pageSize=500)
+        records = records.get("data", []) if records else []
+        for record in records:
+            if (
+                record.get("record") == subdomain
+                and record.get("type") == record_type
+                and (line is None or record.get("viewID") == line)
+            ):
+                return record
+        return None
+
+    def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl, line, extra):
+        """https://www.51dns.com/document/api/4/12.html"""
+        extra["remark"] = extra.get("remark", self.remark)
+        res = self._request(
+            "record/create",
+            domainID=zone_id,
+            value=value,
+            host=subdomain,
+            type=record_type,
+            TTL=ttl,
+            viewID=line,
+            **extra
+        )
+        if res and res.get("recordID"):
+            self.logger.info("Record created: %s", res)
+            return True
+        self.logger.error("Failed to create record: %s", res)
+        return False
+
+    def _update_record(self, zone_id, old_record, value, record_type, ttl, line, extra):
+        """https://www.51dns.com/document/api/4/45.html"""
+        extra["remark"] = extra.get("remark", self.remark)
+        res = self._request(
+            "record/modify", domainID=zone_id, recordID=old_record.get("recordID"), newvalue=value, newTTL=ttl
+        )
+        if res:
+            self.logger.info("Record updated: %s", res)
+            return True
+        self.logger.error("Failed to update record: %s", res)
+        return False

+ 105 - 174
ddns/provider/dnspod.py

@@ -1,186 +1,117 @@
 # coding=utf-8
 """
 DNSPOD API
-DNSPOD 接口解析操作库
-http://www.dnspod.cn/docs/domains.html
-@author: New Future
+@doc: https://docs.dnspod.cn/api/
+@author: NewFuture
 """
 
-from json import loads as jsondecode
-from logging import debug, info, warning
-from os import environ
+from ._base import BaseProvider, TYPE_FORM
 
-try:  # python 3
-    from http.client import HTTPSConnection
-    from urllib.parse import urlencode
-except ImportError:  # python 2
-    from httplib import HTTPSConnection
-    from urllib import urlencode
 
-__author__ = 'New Future'
-
-
-class Config:
-    ID = "token id"
-    TOKEN = "token key"
-    PROXY = None  # 代理设置
-    TTL = None
-
-
-class API:
-    # API 配置
-    SITE = "dnsapi.cn"  # API endpoint
-    METHOD = "POST"  # 请求方法
-    TOKEN_PARAM = "login_token"  # token参数
-    DEFAULT = "默认"  # 默认线路名
-    LENGTH = "length"  # 添加参数
-
-
-def request(action, param=None, **params):
+class DnspodProvider(BaseProvider):
     """
-    发送请求数据
+    DNSPOD API
+    DNSPOD 接口解析操作库
     """
-    if param:
-        params.update(param)
-    params = dict((k, params[k]) for k in params if params[k] is not None)
-    params.update({API.TOKEN_PARAM: '***', 'format': 'json'})
-    info("%s/%s : %s", API.SITE, action, params)
-    params[API.TOKEN_PARAM] = "%s,%s" % (Config.ID, Config.TOKEN)
-    params[API.LENGTH] = "3000"  # 添加参数
-    if Config.PROXY:
-        conn = HTTPSConnection(Config.PROXY)
-        conn.set_tunnel(API.SITE, 443)
-    else:
-        conn = HTTPSConnection(API.SITE)
-
-    conn.request(API.METHOD, '/' + action, urlencode(params), {
-        "Content-type": "application/x-www-form-urlencoded",
-        "User-Agent": "DDNS/%s ([email protected])" % environ.get("DDNS_VERSION", "1.0.0")
-    })
-    response = conn.getresponse()
-    res = response.read().decode('utf8')
-    conn.close()
 
-    if response.status < 200 or response.status >= 300:
-        warning('%s : error[%d]:%s', action, response.status, res)
-        raise Exception(res)
-    else:
-        data = jsondecode(res)
-        debug('%s : result:%s', action, data)
-        if not data:
-            raise Exception("empty response")
-        elif data.get("status", {}).get("code") == "1":
+    API = "https://dnsapi.cn"
+    content_type = TYPE_FORM
+
+    DefaultLine = "默认"
+
+    def _request(self, action, extra=None, **params):
+        # type: (str, dict | None, **(str | int | bytes | bool | None)) -> dict
+        """
+        发送请求数据
+
+        Send request to DNSPod API.
+        Args:
+            action (str): API 动作/Action
+            extra (dict|None): 额外参数/Extra params
+            params (dict): 其它参数/Other params
+        Returns:
+            dict: 响应数据/Response data
+        """
+        # 过滤掉None参数
+        if extra:
+            params.update(extra)
+        params = {k: v for k, v in params.items() if v is not None}
+        params.update({"login_token": "{0},{1}".format(self.auth_id, self.auth_token), "format": "json"})
+        headers = {"User-Agent": "DDNS/{0} ([email protected])".format(self.version)}
+        data = self._http("POST", "/" + action, headers=headers, body=params)
+        if data and data.get("status", {}).get("code") == "1":  # 请求成功
+            return data
+        else:  # 请求失败
+            error_msg = "Unknown error"
+            if data and isinstance(data, dict):
+                error_msg = data.get("status", {}).get("message", "Unknown error")
+            self.logger.warning("DNSPod API error: %s", error_msg)
             return data
-        else:
-            raise Exception(data.get('status', {}))
-
-
-def get_domain_info(domain):
-    """
-    切割域名获取主域名和对应ID
-    """
-    domain_split = domain.split('.')
-    sub, did = None, None
-    main = domain_split.pop()
-    while domain_split:  # 通过API判断,最后两个,三个递增
-        main = domain_split.pop() + '.' + main
-        did = get_domain_id(main)
-        if did:
-            sub = ".".join(domain_split) or '@'
-            # root domain根域名https://github.com/NewFuture/DDNS/issues/9
-            break
-    info('domain_id: %s, sub: %s', did, sub)
-    return did, sub
-
-
-def get_domain_id(domain):
-    """
-        获取域名ID
-        http://www.dnspod.cn/docs/domains.html#domain-info
-    """
-    if not hasattr(get_domain_id, "domain_list"):
-        get_domain_id.domain_list = {}  # "静态变量"存储已查询过的id
-
-    if domain in get_domain_id.domain_list:
-        # 如果已经存在直接返回防止再次请求
-        return get_domain_id.domain_list[domain]
-    else:
-        try:
-            d_info = request('Domain.Info', domain=domain)
-        except Exception as e:
-            info("get_domain_id(%s) error: %s", domain, e)
-            return
-        did = d_info.get("domain", {}).get("id")
-        if did:
-            get_domain_id.domain_list[domain] = did
-            return did
-
-
-def get_records(did, **conditions):
-    """
-        获取记录ID
-        返回满足条件的所有记录[]
-        TODO 大于3000翻页
-        http://www.dnspod.cn/docs/records.html#record-list
-    """
-    if not hasattr(get_records, "records"):
-        get_records.records = {}  # "静态变量"存储已查询过的id
-        get_records.keys = ("id", "name", "type", "line",
-                            "line_id", "enabled", "mx", "value")
-
-    if did not in get_records.records:
-        get_records.records[did] = {}
-        data = request('Record.List', domain_id=did)
-        if data:
-            for record in data.get('records'):
-                get_records.records[did][record["id"]] = {
-                    k: v for (k, v) in record.items() if k in get_records.keys}
-
-    records = {}
-    for (did, record) in get_records.records[did].items():
-        for (k, value) in conditions.items():
-            if record.get(k) != value:
-                break
-        else:  # for else push
-            records[did] = record
-    return records
-
-
-def update_record(domain, value, record_type="A"):
-    """
-    更新记录
-    """
-    info(">>>>>%s(%s)", domain, record_type)
-    domainid, sub = get_domain_info(domain)
-    if not domainid:
-        raise Exception("invalid domain: [ %s ] " % domain)
 
-    records = get_records(domainid, name=sub, type=record_type)
-    result = {}
-    if records:  # update
-        # http://www.dnspod.cn/docs/records.html#record-modify
-        for (did, record) in records.items():
-            if record["value"] != value:
-                debug(sub, record)
-                res = request('Record.Modify', record_id=did, record_line=record["line"].replace("Default", "default").encode(
-                    "utf-8"), value=value, sub_domain=sub, domain_id=domainid, record_type=record_type, ttl=Config.TTL)
-                if res:
-                    get_records.records[domainid][did]["value"] = value
-                    result[did] = res.get("record")
-                else:
-                    result[did] = "update fail!\n" + str(res)
-            else:
-                result[did] = domain
-    else:  # create
-        # http://www.dnspod.cn/docs/records.html#record-create
-        res = request("Record.Create", domain_id=domainid, value=value,
-                      sub_domain=sub, record_type=record_type, record_line=API.DEFAULT, ttl=Config.TTL)
-        if res:
-            did = res.get("record")["id"]
-            get_records.records[domainid][did] = res.get("record")
-            get_records.records[domainid][did].update(
-                value=value, sub_domain=sub, record_type=record_type)
-            result = res.get("record")
-        else:
-            result = domain + " created fail!"
-    return result
+    def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl, line, extra):
+        # type: (str, str, str, str, str, int | str | None, str | None, dict) -> bool
+        """https://docs.dnspod.cn/api/add-record/"""
+        res = self._request(
+            "Record.Create",
+            extra=extra,
+            domain_id=zone_id,
+            sub_domain=subdomain,
+            value=value,
+            record_type=record_type,
+            record_line=line or self.DefaultLine,
+            ttl=ttl,
+        )
+        record = res and res.get("record")
+        if record:  # 记录创建成功
+            self.logger.info("Record created: %s", record)
+            return True
+        else:  # 记录创建失败
+            self.logger.error("Failed to create record: %s", res)
+        return False
+
+    def _update_record(self, zone_id, old_record, value, record_type, ttl, line, extra):
+        # type: (str, dict, str, str, int | str | None, str | None, dict) -> bool
+        """https://docs.dnspod.cn/api/modify-records/"""
+        record_line = (line or old_record.get("line") or self.DefaultLine).replace("Default", "default")
+        res = self._request(
+            "Record.Modify",
+            domain_id=zone_id,
+            record_id=old_record.get("id"),
+            sub_domain=old_record.get("name"),
+            record_type=record_type,
+            value=value,
+            record_line=record_line,
+            extra=extra,
+        )
+        record = res and res.get("record")
+        if record:  # 记录更新成功
+            self.logger.debug("Record updated: %s", record)
+            return True
+        else:  # 记录更新失败
+            self.logger.error("Failed to update record: %s", res)
+            return False
+
+    def _query_zone_id(self, domain):
+        # type: (str) -> str | None
+        """查询域名信息 https://docs.dnspod.cn/api/domain-info/"""
+        res = self._request("Domain.Info", domain=domain)
+        if res and isinstance(res, dict):
+            return res.get("domain", {}).get("id")
+        return None
+
+    def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
+        # type: (str, str, str, str, str | None, dict) -> dict | None
+        """查询记录 list 然后逐个查找 https://docs.dnspod.cn/api/record-list/"""
+        res = self._request(
+            "Record.List", domain_id=zone_id, sub_domain=subdomain, record_type=record_type, line=line
+        )
+        # length="3000"
+        records = res.get("records", [])
+        n = len(records)
+        if not n:
+            self.logger.warning("No record found for [%s] %s<%s>(line: %s)", zone_id, subdomain, record_type, line)
+            return None
+        if n > 1:
+            self.logger.warning("%d records found for %s<%s>(%s):\n %s", n, subdomain, record_type, line, records)
+            return next((r for r in records if r.get("name") == subdomain), None)
+        return records[0]

+ 11 - 8
ddns/provider/dnspod_com.py

@@ -1,15 +1,18 @@
 # coding=utf-8
 """
-DNSPOD API
-DNSPOD 接口解析操作库
+DNSPOD Global (国际版) API
 http://www.dnspod.com/docs/domains.html
-@author: New Future
+@author: NewFuture
 """
 
-from .dnspod import *  # noqa: F403
+from .dnspod import DnspodProvider  # noqa: F401
 
-API.SITE = "api.dnspod.com"  # noqa: F405
-API.DEFAULT = "default"  # noqa: F405
 
-# https://github.com/NewFuture/DDNS/issues/286
-# API.TOKEN_PARAM = "user_token"  # noqa: F405
+class DnspodComProvider(DnspodProvider):
+    """
+    DNSPOD.com Provider (国际版)
+    This class extends the DnspodProvider to use the global DNSPOD API.
+    """
+
+    API = "https://api.dnspod.com"
+    DefaultLine = "default"

+ 41 - 78
ddns/provider/he.py

@@ -1,83 +1,46 @@
 # coding=utf-8
 """
 Hurricane Electric (he.net) API
-Hurricane Electric (he.net) 接口解析操作库
-https://dns.he.net/docs.html
-@author: NN708
+@author: NN708, NewFuture
 """
 
-from logging import debug, info, warning
-
-try:  # python 3
-    from http.client import HTTPSConnection
-    from urllib.parse import urlencode
-except ImportError:  # python 2
-    from httplib import HTTPSConnection
-    from urllib import urlencode
-
-__author__ = 'NN708'
-
-
-class Config:
-    TOKEN = "password"
-    PROXY = None  # 代理设置
-
-
-class API:
-    # API 配置
-    SITE = "dyn.dns.he.net"
-    METHOD = "POST"
-    ACTION = "nic/update"
-    TOKEN_PARAM = "password"  # key name of token param
-
-
-def request(param=None, **params):
-    """
-    发送请求数据
-    """
-    if param:
-        params.update(param)
-
-    params.update({API.TOKEN_PARAM: '***'})
-    info("%s/%s : %s", API.SITE, API.ACTION, params)
-    params[API.TOKEN_PARAM] = Config.TOKEN
-
-    if Config.PROXY:
-        conn = HTTPSConnection(Config.PROXY)
-        conn.set_tunnel(API.SITE, 443)
-    else:
-        conn = HTTPSConnection(API.SITE)
-
-    conn.request(API.METHOD, '/' + API.ACTION, urlencode(params), {
-        "Content-type": "application/x-www-form-urlencoded"
-    })
-    response = conn.getresponse()
-    res = response.read().decode('utf8')
-    conn.close()
-
-    if response.status < 200 or response.status >= 300:
-        warning('%s : error[%d]:%s', API.ACTION, response.status, res)
-        raise Exception(res)
-    else:
-        debug('%s : result:%s', API.ACTION, res)
-        if not res:
-            raise Exception("empty response")
-        elif res[:5] == "nochg" or res[:4] == "good":  # No change or success
-            return res
-        else:
-            raise Exception(res)
-
-
-def update_record(domain, value, record_type="A"):
-    """
-    更新记录
-    """
-    info(">>>>>%s(%s)", domain, record_type)
-    res = request(hostname=domain, myip=value)
-    if res[:4] == "good":
-        result = "Record updated. New IP is: " + res[5:-1]
-    elif res[:5] == "nochg":
-        result = "IP not changed. IP is: " + res[6:-1]
-    else:
-        result = "Record update failed."
-    return result
+from ._base import SimpleProvider, TYPE_FORM
+
+
+class HeProvider(SimpleProvider):
+    API = "https://dyn.dns.he.net"
+    content_type = TYPE_FORM
+    accept = None  # he.net does not require a specific Accept header
+    decode_response = False  # he.net response is plain text, not JSON
+
+    def _validate(self):
+        self.logger.warning(
+            "HE.net provider 缺少充分的真实环境测试,如遇问题请及时在 GitHub Issues 中反馈: %s",
+            "https://github.com/NewFuture/DDNS/issues",
+        )
+        if self.auth_id:
+            raise ValueError("Hurricane Electric (he.net) does not use `id`, use `token(password)` only.")
+        if not self.auth_token:
+            raise ValueError("Hurricane Electric (he.net) requires `token(password)`.")
+
+    def set_record(self, domain, value, record_type="A", ttl=None, line=None, **extra):
+        """
+        使用 POST API 更新或创建 DNS 记录。Update or create DNS record with POST API.
+        https://dns.he.net/docs.html
+        """
+        self.logger.info("%s => %s(%s)", domain, value, record_type)
+        params = {
+            "hostname": domain,  # he.net requires full domain name
+            "myip": value,  # IP address to update
+            "password": self.auth_token,  # Use auth_token as password
+        }
+        try:
+            res = self._http("POST", "/nic/update", body=params)
+            if res and res[:5] == "nochg" or res[:4] == "good":  # No change or success
+                self.logger.info("HE API response: %s", res)
+                return True
+            else:
+                self.logger.error("HE API error: %s", res)
+        except Exception as e:
+            self.logger.error("Error updating record for %s: %s", domain, e)
+        return False

+ 147 - 254
ddns/provider/huaweidns.py

@@ -3,263 +3,156 @@
 HuaweiDNS API
 华为DNS解析操作库
 https://support.huaweicloud.com/api-dns/zh-cn_topic_0037134406.html
-@author: cybmp3
+@author: cybmp3, NewFuture
 """
 
-
+from ._base import BaseProvider, TYPE_JSON
 from hashlib import sha256
 from hmac import new as hmac
-from binascii import hexlify
-from json import loads as jsondecode, dumps as jsonencode
-from logging import debug, info, warning
-from datetime import datetime
-
-try:  # python 3
-    from http.client import HTTPSConnection
-    from urllib.parse import urlencode
-except ImportError:  # python 2
-    from httplib import HTTPSConnection
-    from urllib import urlencode
-
-
-__author__ = 'New Future'
-BasicDateFormat = "%Y%m%dT%H%M%SZ"
-Algorithm = "SDK-HMAC-SHA256"
-
-
-# __all__ = ["request", "ID", "TOKEN", "PROXY"]
-
-
-class Config:
-    ID = "id"  # AK
-    TOKEN = "TOKEN"  # AS
-    PROXY = None  # 代理设置
-    TTL = None
-
-
-class API:
-    # API 配置
-    SCHEME = 'https'
-    SITE = 'dns.myhuaweicloud.com'  # API endpoint
-
-
-def HexEncodeSHA256Hash(data):
-    sha = sha256()
-    sha.update(data)
-    return sha.hexdigest()
-
-
-def StringToSign(canonical_request, t):
-    b = HexEncodeSHA256Hash(canonical_request)
-    return "%s\n%s\n%s" % (Algorithm, datetime.strftime(t, BasicDateFormat), b)
-
-
-def CanonicalHeaders(headers, signed_headers):
-    a = []
-    __headers = {}
-    for key in headers:
-        key_encoded = key.lower()
-        value = headers[key]
-        value_encoded = value.strip()
-        __headers[key_encoded] = value_encoded
-    for key in signed_headers:
-        a.append(key + ":" + __headers[key])
-    return '\n'.join(a) + "\n"
-
-
-def request(method, path, param=None, body=None, **params):
-    # path 是不带host但是 前面需要带 / , body json 字符串或者自己从dict转换下
-    # 也可以自己改成 判断下是不是post 是post params就是body
-    if param:
-        params.update(param)
-
-    query = urlencode(sorted(params.items()))
-    headers = {"content-type": "application/json"}  # 初始化header
-    headers["X-Sdk-Date"] = datetime.strftime(
-        datetime.utcnow(), BasicDateFormat)
-    headers["host"] = API.SITE
-    # 如何后来有需要把header头 key转换为小写 value 删除前导空格和尾随空格
-    sign_headers = []
-    for key in headers:
-        sign_headers.append(key.lower())
-    # 先排序
-    sign_headers.sort()
-
-    if body is None:
-        body = ""
-
-    hex_encode = HexEncodeSHA256Hash(body.encode('utf-8'))
-    # 生成文档中的CanonicalRequest
-    canonical_headers = CanonicalHeaders(headers, sign_headers)
-
-    # 签名中的path 必须 / 结尾
-    if path[-1] != '/':
-        sign_path = path + "/"
-    else:
-        sign_path = path
-
-    canonical_request = "%s\n%s\n%s\n%s\n%s\n%s" % (method.upper(), sign_path, query,
-                                                    canonical_headers, ";".join(sign_headers), hex_encode)
-
-    hashed_canonical_request = HexEncodeSHA256Hash(
-        canonical_request.encode('utf-8'))
-
-    # StringToSign
-    str_to_sign = "%s\n%s\n%s" % (
-        Algorithm, headers['X-Sdk-Date'], hashed_canonical_request)
-
-    secret = Config.TOKEN
-    # 计算签名  HexEncode(HMAC(Access Secret Key, string to sign))
-    signature = hmac(secret.encode(
-        'utf-8'), str_to_sign.encode('utf-8'), digestmod=sha256).digest()
-    signature = hexlify(signature).decode()
-    # 添加签名信息到请求头
-    auth_header = "%s Access=%s, SignedHeaders=%s, Signature=%s" % (
-        Algorithm, Config.ID, ";".join(sign_headers), signature)
-    headers['Authorization'] = auth_header
-    # 创建Http请求
-
-    if Config.PROXY:
-        conn = HTTPSConnection(Config.PROXY)
-        conn.set_tunnel(API.SITE, 443)
-    else:
-        conn = HTTPSConnection(API.SITE)
-    conn.request(method, API.SCHEME + "://" + API.SITE +
-                 path + '?' + query, body, headers)
-    info(API.SCHEME + "://" + API.SITE + path + '?' + query, body)
-    resp = conn.getresponse()
-    data = resp.read().decode('utf8')
-    resp.close()
-    if resp.status < 200 or resp.status >= 300:
-
-        warning('%s : error[%d]: %s', path, resp.status, data)
-        raise Exception(data)
-    else:
-        data = jsondecode(data)
-        debug('%s : result:%s', path, data)
+from json import dumps as jsonencode
+from time import strftime, gmtime
+
+
+class HuaweiDNSProvider(BaseProvider):
+    API = "https://dns.myhuaweicloud.com"
+    content_type = TYPE_JSON
+    algorithm = "SDK-HMAC-SHA256"
+
+    def _validate(self):
+        self.logger.warning(
+            "华为云 DNS provider 缺少充分的真实环境测试,如遇问题请及时在 GitHub Issues 中反馈: %s",
+            "https://github.com/NewFuture/DDNS/issues",
+        )
+        super(HuaweiDNSProvider, self)._validate()
+
+    def _sign_headers(self, headers, signed_headers):
+        a = []
+        _headers = {}
+        for key in headers:
+            key_encoded = key.lower()
+            value = headers[key]
+            value_encoded = value.strip()
+            _headers[key_encoded] = value_encoded
+        for key in signed_headers:
+            a.append(key + ":" + _headers[key])
+        return "\n".join(a) + "\n"
+
+    def _hex_encode_sha256(self, data):
+        sha = sha256()
+        sha.update(data)
+        return sha.hexdigest()
+
+    def _request(self, method, path, **params):
+        # type: (str, str, **Any) -> dict
+        params = {k: v for k, v in params.items() if v is not None}
+        if method.upper() == "GET" or method.upper() == "DELETE":
+            query = self._encode(sorted(params.items()))
+            body = ""
+        else:
+            query = ""
+            body = jsonencode(params)
+
+        date_now = strftime("%Y%m%dT%H%M%SZ", gmtime())
+        headers = {
+            "content-type": self.content_type,
+            "host": self.API.split("://", 1)[1].strip("/"),
+            "X-Sdk-Date": date_now,
+        }
+        sign_headers = [k.lower() for k in headers]
+        sign_headers.sort()
+
+        hex_encode = self._hex_encode_sha256(body.encode("utf-8"))
+        canonical_headers = self._sign_headers(headers, sign_headers)
+        sign_path = path if path[-1] == "/" else path + "/"
+        canonical_request = "%s\n%s\n%s\n%s\n%s\n%s" % (
+            method.upper(),
+            sign_path,
+            query,
+            canonical_headers,
+            ";".join(sign_headers),
+            hex_encode,
+        )
+        hashed_canonical_request = self._hex_encode_sha256(canonical_request.encode("utf-8"))
+
+        str_to_sign = "%s\n%s\n%s" % (self.algorithm, date_now, hashed_canonical_request)
+        secret = self.auth_token
+        signature = hmac(secret.encode("utf-8"), str_to_sign.encode("utf-8"), digestmod=sha256).hexdigest()
+        auth_header = "%s Access=%s, SignedHeaders=%s, Signature=%s" % (
+            self.algorithm,
+            self.auth_id,
+            ";".join(sign_headers),
+            signature,
+        )
+        headers["Authorization"] = auth_header
+        self.logger.debug("Request headers: %s", headers)
+        data = self._http(method, path + "?" + query, headers=headers, body=body)
         return data
 
-
-def get_zone_id(domain):
-    """
-    切割域名获取主域名和对应ID https://support.huaweicloud.com/api-dns/dns_api_62003.html
-    优先匹配级数最长的主域名
-    """
-    zoneid = None
-    domain_slice = domain.split('.')
-    index = len(domain_slice)
-    root_domain = '.'.join(domain_slice[-2:])
-    zones = request('GET', '/v2/zones', limit=500, name=root_domain)['zones']
-    while (not zoneid) and (index >= 2):
-        domain = '.'.join(domain_slice[-index:]) + '.'
-        zone = next((z for z in zones if domain == (z.get('name'))), None)
-        zoneid = zone and zone['id']
-        index -= 1
-    return zoneid
-
-
-def get_records(zoneid, **conditions):
-    """
-        获取记录ID
-        返回满足条件的所有记录[]
-        https://support.huaweicloud.com/api-dns/dns_api_64004.html
-        TODO 大于500翻页
-    """
-    cache_key = zoneid + "_" + \
-        conditions.get('name', "") + "_" + conditions.get('type', "")
-    if not hasattr(get_records, 'records'):
-        get_records.records = {}  # "静态变量"存储已查询过的id
-        get_records.keys = ('id', 'type', 'name', 'records', 'ttl')
-
-    if zoneid not in get_records.records:
-        get_records.records[cache_key] = {}
-
-        data = request('GET', '/v2/zones/' + zoneid + '/recordsets',
-                       limit=500, **conditions)
-
-        # https://{DNS_Endpoint}/v2/zones/2c9eb155587194ec01587224c9f90149/recordsets?limit=&offset=
-        if data:
-            for record in data['recordsets']:
-                info(record)
-                get_records.records[cache_key][record['id']] = {
-                    k: v for (k, v) in record.items() if k in get_records.keys}
-    records = {}
-    for (zid, record) in get_records.records[cache_key].items():
-        for (k, value) in conditions.items():
-            if record.get(k) != value:
-                break
-        else:  # for else push
-            records[zid] = record
-    return records
-
-
-def update_record(domain, value, record_type='A'):
-    """
-        更新记录
-        update
-        https://support.huaweicloud.com/api-dns/UpdateRecordSet.html
-        add
-        https://support.huaweicloud.com/api-dns/dns_api_64001.html
-    """
-    info(">>>>>%s(%s)", domain, record_type)
-    zoneid = get_zone_id(domain)
-    if not zoneid:
-        raise Exception("invalid domain: [ %s ] " % domain)
-    domain += '.'
-    records = get_records(zoneid, name=domain, type=record_type)
-    cache_key = zoneid + "_" + domain + "_" + record_type
-    result = {}
-    if records:  # update
-        for (rid, record) in records.items():
-            if record['records'] != value:
-                """
-                PUT https://{endpoint}/v2/zones/{zone_id}/recordsets/{recordset_id}
-
-                {
-                    "name" : "www.example.com.",
-                    "description" : "This is an example record set.",
-                    "type" : "A",
-                    "ttl" : 3600,
-                    "records" : [ "192.168.10.1", "192.168.10.2" ]
-                }
-                """
-                body = {
-                    "name": domain,
-                    "description": "Managed by DDNS.",
-                    "type": record_type,
-                    "records": [
-                        value
-                    ]
-                }
-                # 如果TTL不为空,则添加到字典中
-                if Config.TTL is not None:
-                    body['ttl'] = Config.TTL
-                res = request('PUT', '/v2/zones/' + zoneid + '/recordsets/' + record['id'],
-                              body=str(jsonencode(body)))
-                if res:
-                    get_records.records[cache_key][rid]['records'] = value
-                    result[rid] = res.get("name")
-                else:
-                    result[rid] = "Update fail!\n" + str(res)
-            else:
-                result[rid] = domain
-    else:  # create
-        body = {
-            "name": domain,
-            "description": "Managed by DDNS.",
-            "type": record_type,
-            "records": [
-                value
-            ]
-        }
-        # 如果TTL不为空,则添加到字典中
-        if Config.TTL is not None:
-            body['ttl'] = Config.TTL
-        res = request('POST', '/v2/zones/' + zoneid + '/recordsets',
-                      body=str(jsonencode(body)))
-        if res:
-            get_records.records[cache_key][res['id']] = res
-            result = res
-        else:
-            result = domain + " created fail!"
-    return result
+    def _query_zone_id(self, domain):
+        """https://support.huaweicloud.com/api-dns/dns_api_62003.html"""
+        domain = domain + "." if not domain.endswith(".") else domain
+        data = self._request("GET", "/v2/zones", search_mode="equal", limit=500, name=domain)
+        zones = data.get("zones", [])
+        zone = next((z for z in zones if domain == z.get("name")), None)
+        zoneid = zone and zone["id"]
+        return zoneid
+
+    def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
+        """
+        v2.1 https://support.huaweicloud.com/api-dns/dns_api_64004.html
+        v2 https://support.huaweicloud.com/api-dns/ListRecordSetsByZone.html
+        """
+        domain = self._join_domain(subdomain, main_domain) + "."
+        data = self._request(
+            "GET",
+            "/v2.1/zones/" + zone_id + "/recordsets",
+            limit=500,
+            name=domain,
+            type=record_type,
+            line_id=line,
+            search_mode="equal",
+        )
+        records = data.get("recordsets", [])
+        record = next((r for r in records if r.get("name") == domain and r.get("type") == record_type), None)
+        return record
+
+    def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl, line, extra):
+        """
+        v2.1 https://support.huaweicloud.com/api-dns/dns_api_64001.html
+        v2 https://support.huaweicloud.com/api-dns/CreateRecordSet.html
+        """
+        domain = self._join_domain(subdomain, main_domain) + "."
+        extra["description"] = extra.get("description", self.remark)
+        res = self._request(
+            "POST",
+            "/v2.1/zones/" + zone_id + "/recordsets",
+            name=domain,
+            type=record_type,
+            records=[value],
+            ttl=ttl,
+            line=line,
+            **extra
+        )
+        if res and res.get("id"):
+            self.logger.info("Record created: %s", res)
+            return True
+        self.logger.warning("Failed to create record: %s", res)
+        return False
+
+    def _update_record(self, zone_id, old_record, value, record_type, ttl, line, extra):
+        """https://support.huaweicloud.com/api-dns/UpdateRecordSet.html (无 line 参数)"""
+        extra["description"] = extra.get("description", self.remark)
+        res = self._request(
+            "PUT",
+            "/v2.1/zones/" + zone_id + "/recordsets/" + old_record["id"],
+            name=old_record["name"],
+            type=record_type,
+            records=[value],
+            ttl=ttl if ttl is not None else old_record.get("ttl"),
+            **extra
+        )
+        if res and res.get("id"):
+            self.logger.info("Record updated: %s", res)
+            return True
+        self.logger.warning("Failed to update record: %s", res)
+        return False

+ 240 - 0
ddns/provider/tencentcloud.py

@@ -0,0 +1,240 @@
+# coding=utf-8
+"""
+Tencent Cloud DNSPod API
+腾讯云 DNSPod API
+
+@author: NewFuture
+"""
+from ._base import BaseProvider, TYPE_JSON
+from hashlib import sha256
+from hmac import new as hmac
+from time import time, strftime, gmtime
+from json import dumps as jsonencode
+
+
+class TencentCloudProvider(BaseProvider):
+    """
+    腾讯云 DNSPod API 提供商
+    Tencent Cloud DNSPod API Provider
+
+    API Version: 2021-03-23
+    Documentation: https://cloud.tencent.com/document/api/1427
+    """
+
+    API = "https://dnspod.tencentcloudapi.com"
+    content_type = TYPE_JSON
+
+    # 腾讯云 DNSPod API 配置
+    service = "dnspod"
+    version_date = "2021-03-23"
+
+    def _sign_tc3(self, method, uri, query, headers, payload, timestamp):
+        """
+        腾讯云 API 3.0 签名算法 (TC3-HMAC-SHA256)
+
+        API 文档: https://cloud.tencent.com/document/api/1427/56189
+
+        Args:
+            method (str): HTTP 方法
+            uri (str): URI 路径
+            query (str): 查询字符串
+            headers (dict): 请求头
+            payload (str): 请求体
+            timestamp (int): 时间戳
+
+        Returns:
+            str: Authorization 头部值
+        """
+        algorithm = "TC3-HMAC-SHA256"
+
+        # Step 1: 构建规范请求串
+        http_request_method = method.upper()
+        canonical_uri = uri
+        canonical_querystring = query or ""
+
+        # 构建规范头部
+        signed_headers_list = []
+        canonical_headers = ""
+        for key in sorted(headers.keys()):
+            if key in ["content-type", "host"]:
+                signed_headers_list.append(key)
+                canonical_headers += "{}:{}\n".format(key, headers[key])
+
+        signed_headers = ";".join(signed_headers_list)
+        hashed_request_payload = sha256(payload.encode("utf-8")).hexdigest()
+
+        canonical_request = "\n".join(
+            [
+                http_request_method,
+                canonical_uri,
+                canonical_querystring,
+                canonical_headers,
+                signed_headers,
+                hashed_request_payload,
+            ]
+        )
+
+        # Step 2: 构建待签名字符串
+        date = strftime("%Y-%m-%d", gmtime())  # 日期
+        credential_scope = "{}/{}/tc3_request".format(date, self.service)
+        hashed_canonical_request = sha256(canonical_request.encode("utf-8")).hexdigest()
+
+        string_to_sign = "\n".join([algorithm, str(timestamp), credential_scope, hashed_canonical_request])
+
+        # Step 3: 计算签名
+        def _sign(key, msg):
+            return hmac(key, msg.encode("utf-8"), sha256).digest()
+
+        secret_date = _sign(("TC3" + self.auth_token).encode("utf-8"), date)
+        secret_service = _sign(secret_date, self.service)
+        secret_signing = _sign(secret_service, "tc3_request")
+        signature = hmac(secret_signing, string_to_sign.encode("utf-8"), sha256).hexdigest()
+
+        # Step 4: 构建 Authorization 头部
+        authorization = "{} Credential={}/{}, SignedHeaders={}, Signature={}".format(
+            algorithm, self.auth_id, credential_scope, signed_headers, signature
+        )
+
+        return authorization
+
+    def _request(self, action, **params):
+        # type: (str, **(str | int | bytes | bool | None)) -> dict | None
+        """
+        发送腾讯云 API 请求
+
+        API 文档: https://cloud.tencent.com/document/api/1427/56187
+
+        Args:
+            action (str): API 操作名称
+            params (dict): 请求参数
+
+        Returns:
+            dict: API 响应结果
+        """
+        params = {k: v for k, v in params.items() if v is not None}
+        timestamp = int(time())
+        # 构建请求头,小写
+        headers = {
+            "content-type": self.content_type,
+            "host": self.API.split("://", 1)[1].strip("/"),
+            "X-TC-Action": action,
+            "X-TC-Version": self.version_date,
+            "X-TC-Timestamp": str(timestamp),
+        }
+
+        # 构建请求体
+        payload = jsonencode(params)
+
+        # 生成签名
+        authorization = self._sign_tc3("POST", "/", "", headers, payload, timestamp)
+        headers["authorization"] = authorization
+
+        # 发送请求
+        response = self._http("POST", "/", body=payload, headers=headers)
+
+        if response and "Response" in response:
+            if "Error" in response["Response"]:
+                error = response["Response"]["Error"]
+                self.logger.error(
+                    "TencentCloud API error: %s - %s",
+                    error.get("Code", "Unknown"),
+                    error.get("Message", "Unknown error"),
+                )
+                return None
+            return response["Response"]
+
+        self.logger.warning("Unexpected response format: %s", response)
+        return None
+
+    def _query_zone_id(self, domain):
+        # type: (str) -> str | None
+        """查询域名的 zone_id (domain id) https://cloud.tencent.com/document/api/1427/56173"""
+        # 使用 DescribeDomain API 查询指定域名的信息
+        response = self._request("DescribeDomain", Domain=domain)
+
+        if not response or "DomainInfo" not in response:
+            self.logger.debug("Domain info not found or query failed for: %s", domain)
+            return None
+
+        domain_id = response.get("DomainInfo", {}).get("DomainId")
+
+        if domain_id is not None:
+            self.logger.debug("Found domain %s with ID: %s", domain, domain_id)
+            return str(domain_id)
+
+        self.logger.debug("Domain ID not found in response for: %s", domain)
+        return None
+
+    def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
+        # type: (str, str, str, str, str | None, dict) -> dict | None
+        """查询 DNS 记录列表 https://cloud.tencent.com/document/api/1427/56166"""
+
+        response = self._request(
+            "DescribeRecordList",
+            DomainId=int(zone_id),
+            Subdomain=subdomain,
+            Domain=main_domain,
+            RecordType=record_type,
+            RecordLine=line,
+            **extra
+        )
+        if not response or "RecordList" not in response:
+            self.logger.debug("No records found or query failed")
+            return None
+
+        records = response["RecordList"]
+        if not records:
+            self.logger.debug("No records found for subdomain: %s", subdomain)
+            return None
+
+        # 查找匹配的记录
+        target_name = subdomain if subdomain and subdomain != "@" else "@"
+        for record in records:
+            if record.get("Name") == target_name and record.get("Type") == record_type.upper():
+                self.logger.debug("Found existing record: %s", record)
+                return record
+
+        self.logger.debug("No matching record found")
+        return None
+
+    def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl, line, extra):
+        """创建 DNS 记录 https://cloud.tencent.com/document/api/1427/56180"""
+        extra["Remark"] = extra.get("Remark", self.remark)
+        response = self._request(
+            "CreateRecord",
+            Domain=main_domain,
+            DomainId=int(zone_id),
+            SubDomain=subdomain,
+            RecordType=record_type,
+            Value=value,
+            RecordLine=line or "默认",
+            TTL=int(ttl) if ttl else None,
+            **extra
+        )
+        if response and "RecordId" in response:
+            self.logger.info("Record created successfully with ID: %s", response["RecordId"])
+            return True
+        self.logger.error("Failed to create record:\n%s", response)
+        return False
+
+    def _update_record(self, zone_id, old_record, value, record_type, ttl, line, extra):
+        """更新 DNS 记录: https://cloud.tencent.com/document/api/1427/56157"""
+        extra["Remark"] = extra.get("Remark", self.remark)
+        response = self._request(
+            "ModifyRecord",
+            Domain=old_record.get("Domain", ""),
+            DomainId=old_record.get("DomainId", int(zone_id)),
+            SubDomain=old_record.get("Name"),
+            RecordId=old_record.get("RecordId"),
+            RecordType=record_type,
+            RecordLine=old_record.get("Line", line or "默认"),
+            Value=value,
+            TTL=int(ttl) if ttl else None,
+            **extra
+        )
+        if response and "RecordId" in response:
+            self.logger.info("Record updated successfully")
+            return True
+
+        self.logger.error("Failed to update record")
+        return False

+ 13 - 17
ddns/util/cache.py

@@ -8,26 +8,22 @@ cache module
 from os import path, stat
 from pickle import dump, load
 from time import time
+from logging import getLogger, Logger  # noqa: F401
 
-from logging import info, debug, warning
 
-try:  # python 3
-    from collections.abc import MutableMapping
-except ImportError:  # python 2
-    from collections import MutableMapping
-
-
-class Cache(MutableMapping):
+class Cache(dict):
     """
     using file to Cache data as dictionary
     """
 
-    def __init__(self, path, sync=False):
+    def __init__(self, path, logger=None, sync=False):
+        # type: (str, Logger | None, bool) -> None
         self.__data = {}
         self.__filename = path
         self.__sync = sync
         self.__time = time()
         self.__changed = False
+        self.__logger = (logger or getLogger()).getChild("Cache")
         self.load()
 
     @property
@@ -44,9 +40,9 @@ class Cache(MutableMapping):
         if not file:
             file = self.__filename
 
-        debug('load cache data from %s', file)
+        self.__logger.debug("load cache data from %s", file)
         if path.isfile(file):
-            with open(self.__filename, 'rb') as data:
+            with open(self.__filename, "rb") as data:
                 try:
                     self.__data = load(data)
                     self.__time = stat(file).st_mtime
@@ -54,9 +50,9 @@ class Cache(MutableMapping):
                 except ValueError:
                     pass
                 except Exception as e:
-                    warning(e)
+                    self.__logger.warning(e)
         else:
-            info('cache file not exist')
+            self.__logger.info("cache file not exist")
 
         self.__data = {}
         self.__time = time()
@@ -64,6 +60,7 @@ class Cache(MutableMapping):
         return self
 
     def data(self, key=None, default=None):
+        # type: (str | None, Any | None) -> dict | Any
         """
         获取当前字典或者制定得键值
         """
@@ -76,12 +73,11 @@ class Cache(MutableMapping):
             return self.__data.get(key, default)
 
     def sync(self):
-        """Sync the write buffer with the cache files and clear the buffer.
-        """
+        """Sync the write buffer with the cache files and clear the buffer."""
         if self.__changed:
-            with open(self.__filename, 'wb') as data:
+            with open(self.__filename, "wb") as data:
                 dump(self.__data, data)
-                debug('save cache data to %s', self.__filename)
+                self.__logger.debug("save cache data to %s", self.__filename)
             self.__time = time()
             self.__changed = False
         return self

+ 22 - 34
ddns/util/config.py

@@ -54,7 +54,7 @@ def parse_array_string(value, enable_simple_split):
     仅当 trim 之后以 '[' 开头以 ']' 结尾时,才尝试使用 ast.literal_eval 解析
     默认返回原始字符串
     """
-    if not isinstance(value, str):
+    if not hasattr(value, "strip"):  # 非字符串
         return value
 
     trimmed = value.strip()
@@ -71,10 +71,10 @@ def parse_array_string(value, enable_simple_split):
     elif enable_simple_split:
         # 尝试使用逗号或分号分隔符解析
         sep = None
-        if ',' in trimmed:
-            sep = ','
-        elif ';' in trimmed:
-            sep = ';'
+        if "," in trimmed:
+            sep = ","
+        elif ";" in trimmed:
+            sep = ";"
         if sep:
             return [item.strip() for item in trimmed.split(sep) if item.strip()]
     return value
@@ -84,9 +84,8 @@ def get_system_info_str():
     system = platform.system()
     release = platform.release()
     machine = platform.machine()
-    arch = platform.architecture()[0]  # '64bit' or '32bit'
-
-    return "{}-{} {} ({})".format(system, release, machine, arch)
+    arch = platform.architecture()
+    return "{}-{} {} {}".format(system, release, machine, arch)
 
 
 def get_python_info_str():
@@ -100,21 +99,13 @@ def init_config(description, doc, version, date):
     配置
     """
     global __cli_args
-    parser = ArgumentParser(
-        description=description, epilog=doc, formatter_class=RawTextHelpFormatter
-    )
+    parser = ArgumentParser(description=description, epilog=doc, formatter_class=RawTextHelpFormatter)
     sysinfo = get_system_info_str()
     pyinfo = get_python_info_str()
     version_str = "v{} ({})\n{}\n{}".format(version, date, pyinfo, sysinfo)
     parser.add_argument("-v", "--version", action="version", version=version_str)
-    parser.add_argument(
-        "-c", "--config", metavar="FILE", help="load config file [配置文件路径]"
-    )
-    parser.add_argument(
-        "--debug",
-        action="store_true",
-        help="debug mode [调试模式等效 --log.level=DEBUG]",
-    )
+    parser.add_argument("-c", "--config", metavar="FILE", help="load config file [配置文件路径]")
+    parser.add_argument("--debug", action="store_true", help="debug mode [开启调试模式]")
 
     # 参数定义
     parser.add_argument(
@@ -182,24 +173,21 @@ def init_config(description, doc, version, date):
         const=False,
         help="disable cache [关闭缓存等效 --cache=false]",
     )
-    parser.add_argument(
-        "--log.file", metavar="FILE", help="log file [日志文件,默认标准输出]"
-    )
+    parser.add_argument("--log.file", metavar="FILE", help="log file [日志文件,默认标准输出]")
     parser.add_argument("--log.level", type=log_level, metavar="|".join(log_levels))
-    parser.add_argument(
-        "--log.format", metavar="FORMAT", help="log format [设置日志打印格式]"
-    )
-    parser.add_argument(
-        "--log.datefmt", metavar="FORMAT", help="date format [日志时间打印格式]"
-    )
+    parser.add_argument("--log.format", metavar="FORMAT", help="log format [设置日志打印格式]")
+    parser.add_argument("--log.datefmt", metavar="FORMAT", help="date format [日志时间打印格式]")
 
     __cli_args = parser.parse_args()
-    if __cli_args.debug:
-        # 如果启用调试模式,则设置日志级别为 DEBUG
+    is_debug = getattr(__cli_args, "debug", False)
+    if is_debug:
+        # 如果启用调试模式,则强制设置日志级别为 DEBUG
         setattr(__cli_args, "log.level", log_level("DEBUG"))
+        if not hasattr(__cli_args, "cache"):
+            setattr(__cli_args, "cache", False)  # 禁用缓存
 
-    is_configfile_required = not get_config("token") and not get_config("id")
-    config_file = get_config("config")
+    config_required = not get_config("token") and not get_config("id")
+    config_file = get_config("config")  # type: str | None # type: ignore
     if not config_file:
         # 未指定配置文件且需要读取文件时,依次查找
         cfgs = [
@@ -212,7 +200,7 @@ def init_config(description, doc, version, date):
     if path.isfile(config_file):
         __load_config(config_file)
         __cli_args.config = config_file
-    elif is_configfile_required:
+    elif config_required:
         error("Config file is required, but not found: %s", config_file)
         # 如果需要配置文件但没有指定,则自动生成
         if generate_config(config_file):
@@ -296,7 +284,7 @@ def generate_config(config_path):
         "$schema": "https://ddns.newfuture.cc/schema/v4.0.json",
         "id": "YOUR ID or EMAIL for DNS Provider",
         "token": "YOUR TOKEN or KEY for DNS Provider",
-        "dns": "dnspod",
+        "dns": "debug",  # DNS Provider, default is print
         "ipv4": ["newfuture.cc", "ddns.newfuture.cc"],
         "ipv6": ["newfuture.cc", "ipv6.ddns.newfuture.cc"],
         "index4": "default",

+ 277 - 0
ddns/util/http.py

@@ -0,0 +1,277 @@
+# coding=utf-8
+"""
+HTTP请求工具模块
+
+HTTP utilities module for DDNS project.
+Provides common HTTP functionality including redirect following support.
+
+@author: NewFuture
+"""
+
+from logging import getLogger
+import ssl
+import os
+
+try:  # python 3
+    from http.client import HTTPSConnection, HTTPConnection, HTTPException
+    from urllib.parse import urlparse
+except ImportError:  # python 2
+    from httplib import HTTPSConnection, HTTPConnection, HTTPException  # type: ignore[no-redef]
+    from urlparse import urlparse  # type: ignore[no-redef]
+
+__all__ = ["send_http_request", "HttpResponse"]
+
+logger = getLogger().getChild(__name__)
+
+
+class HttpResponse(object):
+    """HTTP响应封装类"""
+
+    def __init__(self, status, reason, headers, body):
+        # type: (int, str, list[tuple[str, str]], str) -> None
+        """
+        初始化HTTP响应对象
+
+        Args:
+            status (int): HTTP状态码
+            reason (str): 状态原因短语
+            headers (list[tuple[str, str]]): 响应头列表,保持原始格式和顺序
+            body (str): 响应体内容
+        """
+        self.status = status
+        self.reason = reason
+        self.headers = headers
+        self.body = body
+
+    def get_header(self, name, default=None):
+        # type: (str, str | None) -> str | None
+        """
+        获取指定名称的头部值(不区分大小写)
+
+        Args:
+            name (str): 头部名称
+
+        Returns:
+            str | None: 头部值,如果不存在则返回None
+        """
+        name_lower = name.lower()
+        for header_name, header_value in self.headers:
+            if header_name.lower() == name_lower:
+                return header_value
+        return default
+
+
+def _create_connection(hostname, port, is_https, proxy, verify_ssl):
+    # type: (str, int | None, bool, str | None, bool | str) -> HTTPConnection | HTTPSConnection
+    """创建HTTP/HTTPS连接"""
+    target = proxy or hostname
+
+    if not is_https:
+        conn = HTTPConnection(target, port)
+    else:
+        ssl_context = ssl.create_default_context()
+
+        if verify_ssl is False:
+            # 禁用SSL验证
+            ssl_context.check_hostname = False
+            ssl_context.verify_mode = ssl.CERT_NONE
+        elif hasattr(verify_ssl, "lower") and verify_ssl.lower() not in ("auto", "true"):  # type: ignore[union-attr]
+            # 使用自定义CA证书 lower 判断 str/unicode 兼容 python2
+            try:
+                ssl_context.load_verify_locations(verify_ssl)  # type: ignore[arg-type]
+            except Exception as e:
+                logger.error("Failed to load CA certificate from %s: %s", verify_ssl, e)
+        else:
+            # 默认验证,尝试加载系统证书
+            _load_system_ca_certs(ssl_context)
+        conn = HTTPSConnection(target, port, context=ssl_context)
+
+    # 设置代理隧道
+    if proxy:
+        conn.set_tunnel(hostname, port)  # type: ignore[attr-defined]
+
+    return conn
+
+
+def _load_system_ca_certs(ssl_context):
+    # type: (ssl.SSLContext) -> None
+    """加载系统CA证书"""
+    # 常见CA证书路径
+    ca_paths = [
+        # Linux/Unix常用路径
+        "/etc/ssl/certs/ca-certificates.crt",  # Debian/Ubuntu
+        "/etc/pki/tls/certs/ca-bundle.crt",  # RedHat/CentOS
+        "/etc/ssl/ca-bundle.pem",  # OpenSUSE
+        "/etc/ssl/cert.pem",  # OpenBSD
+        "/usr/local/share/certs/ca-root-nss.crt",  # FreeBSD
+        "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",  # Fedora/RHEL
+        # macOS路径
+        "/usr/local/etc/openssl/cert.pem",  # macOS with Homebrew
+        "/opt/local/etc/openssl/cert.pem",  # macOS with MacPorts
+    ]
+
+    # Windows额外路径
+    if os.name == "nt":
+        ca_paths.append("C:\\Program Files\\Git\\mingw64\\ssl\\cert.pem")
+        ca_paths.append("C:\\Program Files\\OpenSSL\\ssl\\cert.pem")
+
+    loaded_count = 0
+    for ca_path in ca_paths:
+        if os.path.isfile(ca_path):
+            try:
+                ssl_context.load_verify_locations(ca_path)
+                loaded_count += 1
+                logger.debug("Loaded CA certificates from: %s", ca_path)
+            except Exception as e:
+                logger.debug("Failed to load CA certificates from %s: %s", ca_path, e)
+
+    if loaded_count > 0:
+        logger.debug("Successfully loaded CA certificates from %d locations", loaded_count)
+
+
+def _close_connection(conn):
+    # type: (HTTPConnection | HTTPSConnection) -> None
+    """关闭HTTP/HTTPS连接"""
+    try:
+        conn.close()
+    except Exception as e:
+        logger.warning("Failed to close connection: %s", e)
+
+
+def send_http_request(method, url, body=None, headers=None, proxy=None, max_redirects=5, verify_ssl=True):
+    # type: (str, str, str | bytes | None, dict[str, str] | None, str | None, int, bool | str) -> HttpResponse
+    """
+    发送HTTP/HTTPS请求,支持重定向跟随和灵活的SSL验证
+    Send HTTP/HTTPS request with support for redirect following and flexible SSL verification.
+    Args:
+        method (str): HTTP方法,如GET、POST等
+        url (str): 请求的URL
+        body (str | bytes | None): 请求体
+        headers (dict[str, str] | None): 请求头
+        proxy (str | None): 代理地址
+        max_redirects (int): 最大重定向次数
+        verify_ssl (bool | str): 是否验证SSL证书
+    Returns:
+        HttpResponse: 响应对象,包含状态码、头部和解码后的内容
+    Raises:
+        HTTPException: 如果请求失败或重定向次数超过限制
+        ssl.SSLError: 如果SSL验证失败
+    """
+    if max_redirects <= 0:
+        raise HTTPException("Too many redirects")
+
+    # 解析URL
+    url_obj = urlparse(url)
+    is_https = url_obj.scheme == "https"
+    hostname = url_obj.hostname or url_obj.netloc.split(":")[0]
+    request_path = "{}?{}".format(url_obj.path, url_obj.query) if url_obj.query else url_obj.path
+    headers = headers or {}
+
+    # 创建连接
+    actual_verify_ssl = verify_ssl
+    conn = _create_connection(hostname, url_obj.port, is_https, proxy, verify_ssl)
+
+    # 执行请求,处理SSL错误
+    try:
+        conn.request(method, request_path, body, headers)
+        response = conn.getresponse()
+    except ssl.SSLError:
+        _close_connection(conn)
+        if verify_ssl == "auto" and is_https:
+            logger.warning("SSL verification failed, switching to unverified connection %s", url)
+            # 重新连接,忽略SSL验证
+            conn = _create_connection(hostname, url_obj.port, is_https, proxy, False)
+            conn.request(method, request_path, body, headers)
+            response = conn.getresponse()
+            actual_verify_ssl = False
+        else:
+            raise
+
+    # 检查重定向
+    status = response.status
+    if 300 <= status < 400:
+        location = response.getheader("Location")
+        _close_connection(conn)
+        if not location:
+            # 无Location头的重定向
+            logger.warning("Redirect status %d but no Location header", status)
+            location = ""
+
+        # 构建重定向URL
+        redirect_url = _build_redirect_url(location, "{}://{}".format(url_obj.scheme, url_obj.netloc), url_obj.path)
+
+        # 如果重定向URL没有查询字符串,但原始URL有,则附加
+        if url_obj.query and "?" not in redirect_url:
+            redirect_url += "?" + url_obj.query
+
+        # 确定重定向方法:303或302+POST转为GET,其他保持原方法
+        if status == 303 or (status == 302 and method == "POST"):
+            method, body = "GET", None
+            # 如果从POST转为GET,移除相关的头部
+            if headers:
+                headers = {k: v for k, v in headers.items() if k.lower() not in ("content-length", "content-type")}
+
+        logger.info("Redirecting [%d] to: %s", status, redirect_url)
+        # 递归处理重定向
+        return send_http_request(method, redirect_url, body, headers, proxy, max_redirects - 1, actual_verify_ssl)
+
+    # 处理最终响应
+    content_type = response.getheader("Content-Type")
+    response_headers = response.getheaders()
+    raw_body = response.read()
+    _close_connection(conn)
+
+    # 解码响应体并创建响应对象
+    decoded_body = _decode_response_body(raw_body, content_type)
+    return HttpResponse(status, response.reason, response_headers, decoded_body)
+
+
+def _build_redirect_url(location, base, path):
+    # type: (str, str, str) -> str
+    """构建重定向URL,使用简单的字符串操作"""
+    if location.startswith("http"):
+        return location
+
+    if location.startswith("/"):
+        # 绝对路径:使用base的scheme和netloc
+        base_url = urlparse(base)
+        return "{}://{}{}".format(base_url.scheme, base_url.netloc, location)
+    else:
+        base_path = path.rsplit("/", 1)[0] if "/" in path else ""
+        if not base_path.endswith("/"):
+            base_path += "/"
+        return base + base_path + location
+
+
+def _decode_response_body(raw_body, content_type):
+    # type: (bytes, str | None) -> str
+    """解码HTTP响应体,优先使用UTF-8"""
+    if not raw_body:
+        return ""
+
+    # 从Content-Type提取charset
+    charsets = ["utf-8", "gbk", "ascii", "latin-1"]
+    if content_type and "charset=" in content_type.lower():
+        start = content_type.lower().find("charset=") + 8
+        end = content_type.find(";", start)
+        if end == -1:
+            end = len(content_type)
+        charset = content_type[start:end].strip("'\" ").lower()
+        charsets.insert(0, charset)
+        # 处理常见别名
+        if charset == "gb2312":
+            charsets.remove("gbk")
+            charsets.insert(0, "gbk")
+        elif charset == "iso-8859-1":
+            charsets.remove("latin-1")
+            charsets.insert(0, "latin-1")
+
+    # 按优先级尝试解码
+    for encoding in charsets:
+        try:
+            return raw_body.decode(encoding)
+        except (UnicodeDecodeError, LookupError):
+            continue
+
+    # 最终后备:UTF-8替换错误字符
+    return raw_body.decode("utf-8", errors="replace")

+ 20 - 17
ddns/util/ip.py

@@ -4,16 +4,17 @@ from re import compile
 from os import name as os_name, popen
 from socket import socket, getaddrinfo, gethostname, AF_INET, AF_INET6, SOCK_DGRAM
 from logging import debug, error
+
 try:  # python3
     from urllib.request import urlopen, Request
 except ImportError:  # python2
-    from urllib2 import urlopen, Request
+    from urllib2 import urlopen, Request  # type: ignore[import-untyped]  # noqa: F401
 
 # IPV4正则
-IPV4_REG = r'((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])'
+IPV4_REG = r"((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])"
 # IPV6正则
 # https://community.helpsystems.com/forums/intermapper/miscellaneous-topics/5acc4fcf-fa83-e511-80cf-0050568460e4
-IPV6_REG = r'((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))'  # noqa: E501
+IPV6_REG = r"((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))"  # noqa: E501
 
 
 def default_v4():  # 默认连接外网的ipv4
@@ -26,7 +27,7 @@ def default_v4():  # 默认连接外网的ipv4
 
 def default_v6():  # 默认连接外网的ipv6
     s = socket(AF_INET6, SOCK_DGRAM)
-    s.connect(('1:1:1:1:1:1:1:1', 8))
+    s.connect(("1:1:1:1:1:1:1:1", 8))
     ip = s.getsockname()[0]
     s.close()
     return ip
@@ -47,10 +48,12 @@ def local_v4(i=0):  # 本地ipv4地址
 def _open(url, reg):
     try:
         debug("open: %s", url)
-        res = urlopen(
-            Request(url, headers={'User-Agent': 'Mozilla/5.0 ddns'}),  timeout=60
-        ).read().decode('utf8', 'ignore')
-        debug("response: %s",  res)
+        res = (
+            urlopen(Request(url, headers={"User-Agent": "Mozilla/5.0 ddns"}), timeout=60)
+            .read()
+            .decode("utf8", "ignore")
+        )
+        debug("response: %s", res)
         return compile(reg).search(res).group()
     except Exception as e:
         error(e)
@@ -69,10 +72,10 @@ def _ip_regex_match(parrent_regex, match_regex):
     ip_pattern = compile(parrent_regex)
     matcher = compile(match_regex)
 
-    if os_name == 'nt':  # windows:
-        cmd = 'ipconfig'
+    if os_name == "nt":  # windows:
+        cmd = "ipconfig"
     else:
-        cmd = 'ip address || ifconfig 2>/dev/null'
+        cmd = "ip address || ifconfig 2>/dev/null"
 
     for s in popen(cmd).readlines():
         addr = ip_pattern.search(s)
@@ -81,16 +84,16 @@ def _ip_regex_match(parrent_regex, match_regex):
 
 
 def regex_v4(reg):  # ipv4 正则提取
-    if os_name == 'nt':  # Windows: IPv4 xxx: 192.168.1.2
-        regex_str = r'IPv4 .*: ((?:\d{1,3}\.){3}\d{1,3})\W'
+    if os_name == "nt":  # Windows: IPv4 xxx: 192.168.1.2
+        regex_str = r"IPv4 .*: ((?:\d{1,3}\.){3}\d{1,3})\W"
     else:
-        regex_str = r'inet (?:addr\:)?((?:\d{1,3}\.){3}\d{1,3})[\s/]'
+        regex_str = r"inet (?:addr\:)?((?:\d{1,3}\.){3}\d{1,3})[\s/]"
     return _ip_regex_match(regex_str, reg)
 
 
 def regex_v6(reg):  # ipv6 正则提取
-    if os_name == 'nt':  # Windows: IPv4 xxx: ::1
-        regex_str = r'IPv6 .*: ([\:\dabcdef]*)?\W'
+    if os_name == "nt":  # Windows: IPv4 xxx: ::1
+        regex_str = r"IPv6 .*: ([\:\dabcdef]*)?\W"
     else:
-        regex_str = r'inet6 (?:addr\:\s*)?([\:\dabcdef]*)?[\s/%]'
+        regex_str = r"inet6 (?:addr\:\s*)?([\:\dabcdef]*)?[\s/%]"
     return _ip_regex_match(regex_str, reg)

+ 17 - 1
doc/cli.md

@@ -89,6 +89,7 @@ API访问ID或用户标识。
   - Cloudflare: 填写邮箱地址(使用Token时可留空)
   - HE.net: 可留空
   - 华为云: 填写Access Key ID (AK)
+  - Callback: 填写回调URL地址(支持变量替换)
   - 其他服务商: 根据各自要求填写ID
 
 ### `--token TOKEN`
@@ -96,7 +97,22 @@ API访问ID或用户标识。
 API授权令牌或密钥。
 
 - **必需**: 是
-- **说明**: 部分平台称为Secret Key,请妥善保管
+- **说明**:
+  - 大部分平台: API密钥或Secret Key
+  - Callback: POST请求参数(JSON字符串),为空时使用GET请求
+  - 请妥善保管敏感信息
+
+**Callback配置示例**:
+
+```bash
+# GET方式回调
+ddns --dns callback --id "https://api.example.com/ddns?domain=__DOMAIN__&ip=__IP__" --token ""
+
+# POST方式回调
+ddns --dns callback --id "https://api.example.com/ddns" --token '{"api_key": "your_key", "domain": "__DOMAIN__"}'
+```
+
+详细配置请参考:[Callback Provider 配置文档](providers/callback.md)
 
 ## 域名配置参数
 

+ 299 - 0
doc/dev/provider.md

@@ -0,0 +1,299 @@
+# 开发指南:如何实现一个新的 DNS Provider
+
+本指南介绍如何基于不同的抽象基类,快速实现一个自定义的 DNS 服务商适配类,支持动态 DNS 记录的创建与更新。
+
+## 📦 目录结构
+
+```text
+ddns/
+├── provider/
+│   ├── _base.py         # 抽象基类 SimpleProvider 和 BaseProvider
+│   └── myprovider.py    # 你的新服务商实现
+tests/
+├── base_test.py         # 共享测试工具和基类
+├── test_provider_*.py   # 各个Provider的单元测试文件
+└── README.md            # 测试指南
+```
+
+---
+
+## 🚀 快速开始
+
+DDNS 提供两种抽象基类,根据DNS服务商的API特性选择合适的基类:
+
+### 1. SimpleProvider - 简单DNS服务商
+
+适用于只提供简单更新接口,不支持查询现有记录的DNS服务商。
+
+**必须实现的方法:**
+
+| 方法 | 说明 | 是否必须 |
+|------|------|----------|
+| `set_record(domain, value, record_type="A", ttl=None, line=None, **extra)` | **更新或创建DNS记录** | ✅ 必须 |
+| `_validate()` | **验证认证信息** | ❌ 可选(有默认实现) |
+
+**适用场景:**
+
+- 只提供更新接口的DNS服务商(如HE.net)
+- 不需要查询现有记录的简单场景
+- 调试和测试用途
+- 回调(Webhook)类型的DNS更新
+
+### 2. BaseProvider - 完整DNS服务商  ⭐️ 推荐
+
+适用于提供完整CRUD操作的标准DNS服务商API。
+
+**必须实现的方法:**
+
+| 方法 | 说明 | 是否必须 |
+|------|------|----------|
+| `_query_zone_id(domain)` | **查询主域名的Id** (zone_id) | ✅ 必须 |
+| `_query_record(zone_id, subdomain, main_domain, record_type, line=None, extra=None)` | **查询当前 DNS 记录** | ✅ 必须 |
+| `_create_record(zone_id, subdomain, main_domain, value, record_type, ttl=None, line=None, extra=None)` | **创建新记录** | ✅ 必须 |
+| `_update_record(zone_id, old_record, value, record_type, ttl=None, line=None, extra=None)` | **更新现有记录** | ✅ 必须 |
+| `_validate()` | **验证认证信息** | ❌ 可选(有默认id和token必填) |
+
+**内置功能:**
+
+- ✅ SimpleProvider的所有功能
+- 🎯 自动记录管理(查询→创建/更新的完整流程)
+- 💾 缓存机制
+- 📝 详细的操作日志和错误处理
+
+**适用场景:**
+
+- 提供完整REST API的DNS服务商(如Cloudflare、阿里云DNS)
+- 需要查询现有记录状态的场景
+- 支持精确的记录管理和状态跟踪
+
+## 🔧 实现示例
+
+### SimpleProvider 示例
+
+适用于简单DNS服务商,参考现有实现:
+
+- [`provider/he.py`](/ddns/provider/he.py): Hurricane Electric DNS更新
+- [`provider/debug.py`](/ddns/provider/debug.py): 调试用途,打印IP地址
+- [`provider/callback.py`](/ddns/provider/callback.py): 回调/Webhook类型DNS更新
+
+> provider/mysimpleprovider.py
+
+```python
+# coding=utf-8
+"""
+自定义简单 DNS 服务商示例
+@author: YourGithubUsername
+"""
+from ._base import SimpleProvider, TYPE_FORM
+
+class MySimpleProvider(SimpleProvider):
+    """
+    示例SimpleProvider实现
+    支持简单的DNS记录更新,适用于大多数简单DNS API
+    """
+    API = 'https://api.simpledns.com'
+    ContentType = TYPE_FORM          # 或 TYPE_JSON
+    DecodeResponse = False           # 如果返回纯文本而非JSON,设为False
+
+    def _validate(self):
+        """验证认证信息(可选重写)"""
+        super(MySimpleProvider, self)._validate()
+        # 添加特定的验证逻辑,如检查API密钥格式
+        if not self.auth_token or len(self.auth_token) < 16:
+            raise ValueError("Invalid API token format")
+
+    def set_record(self, domain, value, record_type="A", ttl=None, line=None, **extra):
+        """更新DNS记录 - 必须实现"""
+        # logic to update DNS record
+```
+
+### BaseProvider 示例
+
+适用于标准DNS服务商,参考现有实现:
+
+- [`provider/dnspod.py`](/ddns/provider/dnspod.py): POST 表单数据,无签名
+- [`provider/cloudflare.py`](/ddns/provider/cloudflare.py): RESTful JSON,无签名
+- [`provider/alidns.py`](/ddns/provider/alidns.py): POST 表单+sha256参数签名
+- [`provider/huaweidns.py`](/ddns/provider/huaweidns.py): RESTful JSON,参数header签名
+
+> provider/myprovider.py
+
+```python
+# coding=utf-8
+"""
+自定义标准 DNS 服务商示例
+@author: YourGithubUsername
+"""
+from ._base import BaseProvider, TYPE_JSON
+
+class MyProvider(BaseProvider):
+    """
+    示例BaseProvider实现
+    适用于提供完整CRUD API的DNS服务商
+    """
+    API = 'https://api.exampledns.com'
+    ContentType = TYPE_JSON  # 或 TYPE_FORM
+
+    def _request(self, action, **params):
+        # type: (str, **(str | int | bytes | bool | None)) -> dict
+        """[推荐]封装通用请求逻辑,处理认证和公共参数"""
+
+
+    def _query_zone_id(self, domain):
+        # type: (str) -> str
+        """查询主域名的Zone ID"""
+        # 精确查找 或者 list匹配
+
+    def _query_record(self, zone_id, subdomain, main_domain, record_type, line=None, extra=None):
+        # type: (str, str, str, int | None, str | None, dict | None) -> Any
+        """查询现有DNS记录"""
+
+
+    def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl=None, line=None, extra=None):
+        # type: (str, str, str, str, int | None, str | None, dict | None) -> bool
+        """创建新的DNS记录"""
+
+    def _update_record(self, zone_id, old_record, value, record_type, ttl=None, line=None, extra=None):
+        # type: (str, str, str, str, int | None, str | None, dict | None) -> bool
+        """更新现有DNS记录"""
+```
+
+---
+
+## ✅ 开发最佳实践
+
+### 选择合适的基类
+
+1. **SimpleProvider** - 功能不完整的DNS服务商
+   - ✅ DNS服务商只提供更新API
+   - ✅ 不需要查询现有记录
+
+2. **BaseProvider** - 适合标准和复杂场景
+   - ✅ DNS服务商提供完整查询,创建,修改 API
+   - ✅ 需要精确的记录状态管理
+   - ✅ 支持复杂的域名解析逻辑
+
+### 通用开发建议
+
+#### 🌐 HTTP请求处理
+
+```python
+# 使用内置的_http方法,自动处理代理、编码、日志
+response = self._http("POST", path, params=params, headers=headers)
+
+```
+
+#### 🔒 格式验证
+
+```python
+def _validate(self):
+    """认证信息验证示例"""
+    super(MyProvider, self)._validate()
+    # 检查API密钥格式
+    if not self.auth_token or len(self.auth_token) < 16:
+        raise ValueError("API token must be at least 16 characters")
+```
+
+#### 📝 日志记录
+
+```python
+if result:
+    self.logger.info("DNS record got: %s", result.get("id"))
+    return True
+else:
+    self.logger.warning("DNS record update returned false")
+
+```
+
+---
+
+## 🧪 测试和调试
+
+### 单元测试
+
+每个Provider都应该有完整的单元测试。项目提供统一的测试基类和工具:
+
+```python
+# tests/test_provider_myprovider.py
+from base_test import BaseProviderTestCase, unittest, patch, MagicMock
+from ddns.provider.myprovider import MyProvider
+
+class TestMyProvider(BaseProviderTestCase):
+    def setUp(self):
+        super(TestMyProvider, self).setUp()
+        # Provider特定的setup
+    
+    def test_init_with_basic_config(self):
+        """测试基本初始化"""
+   
+```
+
+### 运行测试
+
+```bash
+# 运行所有测试
+python -m unittest discover tests -v
+
+# 运行特定Provider测试
+python -m unittest tests.test_provider_myprovider -v
+
+# 运行特定测试方法
+python tests/test_provider_myprovider.py
+```
+
+---
+
+## 📚 更多资源和最佳实践
+
+### 🏗️ 项目结构建议
+
+```text
+ddns/
+├── provider/
+│   ├── _base.py              # 基类定义
+│   ├── myprovider.py         # 你的Provider实现
+│   └── __init__.py           # 导入和注册
+tests/
+├── base_test.py              # 共享测试基类
+├── test_provider_myprovider.py  # 你的Provider测试
+└── README.md                 # 测试指南
+```
+
+### 📖 参考实现
+
+**SimpleProvider 参考:**
+
+- [`provider/he.py`](/ddns/provider/he.py) - Hurricane Electric (简单表单提交)
+- [`provider/debug.py`](/ddns/provider/debug.py) - 调试工具 (仅打印信息)
+- [`provider/callback.py`](/ddns/provider/callback.py) - 回调/Webhook模式
+
+**BaseProvider 参考:**
+
+- [`provider/cloudflare.py`](/ddns/provider/cloudflare.py) - RESTful JSON API
+- [`provider/alidns.py`](/ddns/provider/alidns.py) - POST+签名认证
+- [`provider/dnspod.py`](/ddns/provider/dnspod.py) - POST表单数据提交
+
+### 🛠️ 开发工具推荐
+
+- 本地开发环境:VSCode
+- 在线代码编辑器:GitHub Codespaces 或 github.dev
+
+### 🎯 常见问题解决
+
+1. **Q: 为什么选择SimpleProvider而不是BaseProvider?**
+   - A: 如果DNS服务商只提供更新API,没有查询API,选择SimpleProvider更简单高效
+
+---
+
+## 🎉 总结
+
+### 快速检查清单
+
+- [ ] 选择了合适的基类(`SimpleProvider` vs `BaseProvider`)
+- [ ] 实现了所有必需的方法(GPT或者Copilot辅助)
+- [ ] 添加了适当的错误处理和日志记录
+- [ ] 编写了完整的单元测试(使用GPT或Copilot生成)
+- [ ] 测试了各种边界情况和错误场景
+- [ ] 更新了相关文档
+
+**Happy Coding! 🚀**

+ 144 - 180
doc/docker.md

@@ -1,151 +1,152 @@
-# DDNS Docker 使用文档
-
-本文档详细说明如何使用 Docker 方式运行 DDNS 工具,包括基本用法、高级配置、多种网络模式以及常见问题解决方案。
-可移植性最佳。
-
-## 基本介绍
-
-DDNS 官方提供了优化过的 Docker 镜像,具有以下特点:
-
-- 基于 Alpine Linux,最终编译后的镜像体积小(< 7MB)
-- 支持多种硬件架构(amd64、arm64、arm/v7、arm/v6、ppc64le、s390x、386、mips64le)
-- 内置定时任务,默认每 5 分钟自动更新一次
-- 无需外部依赖,开箱即用
-- 性能优化,资源占用低
-
-## 快速开始
+# DDNS Docker
+
+- 基本特性
+    -   基于 Alpine Linux,最终编译后的镜像体积小(< 7MB)
+    -   支持多种硬件架构(amd64、arm64、arm/v7、arm/v6、ppc64le、s390x、386、mips64le)
+    -   内置定时任务,默认每 5 分钟自动更新一次
+    -   无需外部依赖,开箱即用, 性能优化,资源占用低
+-   配置方式:
+    -   [CLI 命令行参数](cli.md)
+    -   [JSON 配置文件](json.md)
+    -   [Env 环境变量](env.md)
+
+> Features:
+>
+> -   Docker compatible, cross-platform, lightweight image (<7MB), multi-arch support
+> -   Built-in scheduler (auto update every 5 min)
+> -   No external dependencies, optimized performance, low resource usage
+> -   Multiple configuration methods: CLI, JSON file, environment variables
+
+## 镜像说明
 
 ### 镜像版本
 
-DDNS Docker 镜像特殊版本:
+DDNS 镜像版本(Docker Tag)
 
-- `latest` 最新稳定版(默认)
-- `next` 下一个版本(最新beta版)
-- `edge` 最新开发版(不稳定), 同步master分支
+-   `latest`: 最新稳定版(Default stable release )
+-   `next`: 下一个版本(Next beta version)
+-   `edge` 最新开发版,不稳定(Latest dev build, unstable, master branch)
 
 ```bash
 docker pull newfuture/ddns:latest
+docker pull newfuture/ddns:next
 ```
 
 您也可以指定特定版本,例如:
 
 ```bash
-docker pull newfuture/ddns:v2.8.0
+docker pull newfuture/ddns:v4.0.0
 ```
 
-### 基本运行方式
+### 镜像源 (image sources)
+
+镜像会同步发布到以下源:
+
+-   [Docker Hub](https://hub.docker.com/r/newfuture/ddns): `docker.io/newfuture/ddns`
+-   [GitHub Packages](https://github.com/newfuture/DDNS/pkgs/container/ddns): `ghcr.io/newfuture/ddns`
+
+supports `docker pull ghcr.io/newfuture/ddns`
+
+## 运行方式 docker run
 
 DDNS Docker 镜像支持三种配置方式:命令行,环境变量和配置文件。
 
-当设置了命令行参数时,容器将直接运行 DDNS 程序,而不会启用定时任务。
-如果您需要定时任务,请使用环境变量或配置文件方式。
+-   当设置了命令行参数时,容器将**直接运行单次执行 DDNS 程序,而不会启用定时任务**
+-   如果您需要定时任务,请使用环境变量或配置文件方式。
 
-#### 使用命令行参数
+> DDNS Docker image supports three configuration methods: command line, environment variables, and config file.
+>
+> -   If command line arguments are set, the container runs DDNS directly (no scheduler).
+>     -   See [CLI docs](cli.md) for details.
+>     -   This mode is suitable for one-time runs or debugging.
+> -   For scheduled updates, use environment variables or config file.
+>     -   See [Environment Variable Docs](env.md) for all supported variables.
+>     -   Mount your local config directory to `/ddns/` in the container. See [JSON Config Docs](json.md) for details.
 
-可以参考[命令行参数说明](cli.md)获取详细的参数列表。
-此时 `docker run --name=ddns --network=host newfuture/ddns` 就相当于 `ddns` 命令行,不会执行定时任务。
+**注意** (docker run 时的注意事项):
 
-此方式适合需要一次性运行或调试的场景。
+-   使用了 `-v` 挂载配置文件或目录,确保容器内的 `/ddns/` 目录包含有效的配置文件(如 `config.json`),否则 DDNS 将无法正常工作。
+-   使用了 `--network host`,请确保您的 Docker 守护进程已正确配置以支持此模式。
+-   使用 `-d` 参数可以让容器在后台运行, 使用前请确保您了解 Docker 的基本操作。
+-   使用 `-e DDNS_XXX=YYY` 参数可以设置环境变量,容器内的 DDNS 程序会自动读取这些变量。
 
-```bash
-docker run --name=ddns --network=host newfuture/ddns -h
-```
+### 使用命令行参数 CLI
 
-#### 使用环境变量
+可以参考[命令行参数说明](cli.md)获取详细的参数列表。
+此时 `docker run -v /local/config/:/ddns/  --name=ddns --network=host newfuture/ddns` 就相当于 `ddns` 命令行,不会执行定时任务。
+
+此方式适合需要一次性运行或调试的场景, 参数与 DDNS 命令行参数一致。
 
 ```bash
-docker run -d \
-  -e DDNS_DNS=dnspod \
-  -e DDNS_ID=12345 \
-  -e DDNS_TOKEN=mytokenkey \
-  -e DDNS_IPV4=example.com,www.example.com \
-  -e DDNS_INDEX4=['public',0] \
-  -e DDNS_IPV6=example.com,ipv6.example.com \
-  --network host \
-  --name ddns \
-  newfuture/ddns
+# 查看ddns命令行参数 等价于 ddns -h
+docker run --rm newfuture/ddns -h
+# 加上ddns的 --debug 参数可以启用调试模式(或者 --log.level=debug)
+docker run --rm --network=host newfuture/ddns --debug --dns=dnspod --id=12345 --token=mytokenkey --ipv4=www.example.com --ipv4=ipv4.example.com --index4 public
+# 容器内调试
+docker run -it newfuture/ddns sh
 ```
 
-想要了解所有支持的环境变量,请参考[环境变量配置说明](env.md)。
+### 使用配置文件 JSON
 
-#### 使用配置文件
+Docker 容器内的工作目录是 `/ddns/`,默认配置文件会被映射到容器内的 `/ddns/config.json`。
 
 ```bash
-docker run -d \
-  -v /host/config/:/ddns/ \
-  --network host \
-  --name ddns \
-  newfuture/ddns
+docker run -d -v /host/config/:/ddns/ newfuture/ddns
 ```
 
-其中 `/host/config/` 是您本地包含 `config.json` 的目录。Docker 容器内的工作目录是 `/ddns/`,配置文件会被映射到容器内的 `/ddns/config.json`。
-
+其中 `/host/config/` 是您本地包含 `config.json` 的目录。
 详见 `config.json` 的内容可以参考 [JSON 配置文件说明](json.md)。
 
-## 网络模式
-
-### host 网络模式(推荐)
+### 使用环境变量 ENV
 
-使用 `--network host` 可让容器直接使用宿主机的网络,这样 DDNS 可以正确获取宿主机的 IP 地址
+环境变量和命令行参数类似, 加上 DDNS 前缀,推荐全大写。数组类型需要使用 JSON 格式或者单引号包裹。
 
-对于Public 或者 url 通常不需要设置 host
+当然也可以使用 `--env-file` 参数来加载环境变量文件。
 
 ```bash
 docker run -d \
   -e DDNS_DNS=dnspod \
   -e DDNS_ID=12345 \
   -e DDNS_TOKEN=mytokenkey \
-  -e DDNS_IPV4=example.com \
+  -e DDNS_IPV4=example.com,www.example.com \
+  -e DDNS_INDEX4=['public',0]
   --network host \
+  --name ddns \
   newfuture/ddns
 ```
 
-### bridge 网络模式(默认)
+想要了解所有支持的环境变量,请参考[环境变量配置说明](env.md)。
 
-如果您不想使用 host 网络模式,也可以使用默认的 bridge 模式,但需要注意此时容器获取的是内部 IP,您需要使用 `public` 模式获取公网 IP:
 
-```bash
-docker run -d \
-  -e DDNS_DNS=dnspod \
-  -e DDNS_ID=12345 \
-  -e DDNS_TOKEN=mytokenkey \
-  -e DDNS_IPV4=example.com \
-  -e DDNS_INDEX4=public \
-  newfuture/ddns
-```
+## 网络模式 Network
 
-## 高级配置
+> **Network Modes:**
+>
+> -   `host`: Container uses host network, recommended for correct IP detection
+> -   `bridge` (default): Container has its own IP, use `public` mode to get public IP
 
-### 自定义定时更新频率
 
-默认情况下,容器每 5 分钟执行一次 DDNS 更新。您可以通过挂载自定义的 crontab 文件来修改定时策略:
+### host 网络模式
 
-```bash
-# 创建自定义 crontab 文件
-echo "*/10 * * * * cd /ddns && /bin/ddns" > mycron
-# 挂载自定义 crontab 文件
-docker run -d \
-  -v /path/to/config/:/ddns/ \
-  -v $(pwd)/mycron:/etc/crontabs/root \
-  --network host \
-  newfuture/ddns
-```
-
-### 一次性运行(不启用定时任务)
+使用 `--network host` 可让容器直接使用宿主机的网络,这样 DDNS 可以正确获取宿主机的 IP 地址。
 
-如果您只想执行一次更新而不启用定时任务,可以直接传递参数给容器:
+对于 Public 或者 url 通常不需要设置 host
 
 ```bash
-docker run --rm \
+docker run -d \
   -e DDNS_DNS=dnspod \
   -e DDNS_ID=12345 \
   -e DDNS_TOKEN=mytokenkey \
   -e DDNS_IPV4=example.com \
   --network host \
-  newfuture/ddns --debug
+  newfuture/ddns
 ```
 
-这里 `--debug` 是传递给 DDNS 程序的参数,启用调试模式。任何以 `-` 开头的参数都会被传递给 DDNS 程序。
+### bridge 网络模式(默认)
+
+如果您不想使用 host 网络模式,也可以使用默认的 bridge 模式,但需要注意此时容器具有 IP,您需要使用 `public` 模式获取公网 IP:
+
+## 高级配置
 
 ### 多域名配置
 
@@ -157,31 +158,41 @@ docker run -d \
   -e DDNS_ID=12345 \
   -e DDNS_TOKEN=mytokenkey \
   -e DDNS_IPV4='["example.com", "www.example.com", "sub.example.com"]' \
-  -e DDNS_IPV6='["example.com", "ipv6.example.com"]' \
   --network host \
   newfuture/ddns
 ```
 
-### 启用IPv6支持
+命令行参数方式配置多域名:
 
-要在Docker容器中使用IPv6,需要确保Docker守护程序配置了IPv6支持:
+```bash
+docker run --rm --network host newfuture/ddns \
+  --dns dnspod \
+  --id 12345 \
+  --token mytokenkey \
+  --ipv4 ipv4.example.com \
+  --ipv4 www.example.com
+```
+
+### 启用 IPv6 支持
+
+要在 Docker 容器中使用 IPv6,需要确保 Docker 守护程序配置了 IPv6 支持:
 
 1. 编辑 `/etc/docker/daemon.json`:
 
 ```json
 {
-  "ipv6": true,
-  "fixed-cidr-v6": "fd00::/80"
+    "ipv6": true,
+    "fixed-cidr-v6": "fd00::/80"
 }
 ```
 
-2. 重启Docker服务:
+2. 重启 Docker 服务:
 
 ```bash
 sudo systemctl restart docker
 ```
 
-3. 启动容器时启用IPv6:
+3. 启动容器时启用 IPv6:
 
 ```bash
 docker run -d \
@@ -200,33 +211,32 @@ docker run -d \
 ### 基本环境变量配置
 
 ```yaml
-version: '3'
+version: "3"
 services:
-  ddns:
-    image: newfuture/ddns:latest
-    restart: always
-    network_mode: host
-    environment:
-      - DDNS_DNS=dnspod
-      - DDNS_ID=12345
-      - DDNS_TOKEN=mytokenkey
-      - DDNS_IPV4=example.com,www.example.com
-      - DDNS_IPV6=example.com,ipv6.example.com
-      - DDNS_TTL=600
-      - DDNS_LOG_LEVEL=INFO
+    ddns:
+        image: newfuture/ddns:latest
+        restart: always
+        network_mode: host
+        environment:
+            - DDNS_DNS=dnspod
+            - DDNS_ID=12345
+            - DDNS_TOKEN=mytokenkey
+            - DDNS_IPV4=example.com,www.example.com
+            - DDNS_INDEX4=['public','url:https://api.ipify.org']
+            - DDNS_LOG_LEVEL=WARNING
 ```
 
 ### 使用配置文件
 
 ```yaml
-version: '3'
+version: "3"
 services:
-  ddns:
-    image: newfuture/ddns:latest
-    restart: always
-    network_mode: host
-    volumes:
-      - ./config:/ddns
+    ddns:
+        image: newfuture/ddns:latest
+        restart: always
+        network_mode: host
+        volumes:
+            - ./config:/ddns
 ```
 
 运行 Docker Compose:
@@ -239,6 +249,8 @@ docker-compose up -d
 
 如果您需要在容器中添加其他工具或自定义环境,可以基于官方镜像创建自己的 Dockerfile:
 
+> You can extend the official image with your own tools/scripts via Dockerfile.
+
 ```dockerfile
 FROM newfuture/ddns:latest
 
@@ -253,34 +265,27 @@ RUN chmod +x /bin/custom-script.sh
 # ENTRYPOINT ["/bin/custom-script.sh"]
 ```
 
-## 容器日志查看
-
-查看容器输出日志:
-
-```bash
-docker logs ddns
-```
-
-实时跟踪日志:
-
-```bash
-docker logs -f ddns
-```
-
 ## 排障和常见问题
 
-### 容器无法获取正确的IP地址
+> **Troubleshooting & FAQ:**
+>
+> -   Can't get correct IP: use `--network host` or set `DDNS_INDEX4=public`
+> -   No scheduled updates: check logs, container status, or run update manually
+> -   Container exits immediately: run with `-it` for debugging, check config
+> -   Network issues: check connectivity, set HTTP proxy if needed
+
+### 容器无法获取正确的 IP 地址
 
-**问题**: DDNS无法正确获取主机IP
+**问题**: DDNS 无法正确获取主机 IP
 
 **解决方案**:
 
 1. 使用 `--network host` 网络模式
-2. 或者设置 `-e DDNS_INDEX4=public` 强制使用公网API获取IP
+2. 或者设置 `-e DDNS_INDEX4=public` 强制使用公网 API 获取 IP
 
 ### 未收到定时任务更新
 
-**问题**: 容器运行但不自动更新DNS
+**问题**: 容器运行但不自动更新 DNS
 
 **解决方案**:
 
@@ -299,58 +304,17 @@ docker logs -f ddns
 
 ### 网络连接问题
 
-**问题**: 容器无法连接到DNS服务商API
+**问题**: 容器无法连接到 DNS 服务商 API
 
 **解决方案**:
 
 1. 检查网络连接 `docker exec ddns ping api.dnspod.cn`
-2. 配置HTTP代理 `-e DDNS_PROXY=http://proxy:port`
-
-## 高级主题
-
-### 持久化数据
-
-为了保存DDNS的缓存数据,避免频繁API调用,可以挂载缓存目录:
-
-```bash
-docker run -d \
-  -e DDNS_DNS=dnspod \
-  -e DDNS_ID=12345 \
-  -e DDNS_TOKEN=mytokenkey \
-  -e DDNS_IPV4=example.com \
-  -e DDNS_CACHE=/ddns/cache.json \
-  -v /path/to/cache:/ddns \
-  --network host \
-  newfuture/ddns
-```
-
-### 容器健康检查
-
-Docker Compose配置添加健康检查:
-
-```yaml
-version: '3'
-services:
-  ddns:
-    image: newfuture/ddns:latest
-    restart: always
-    network_mode: host
-    environment:
-      - DDNS_DNS=dnspod
-      - DDNS_ID=12345
-      - DDNS_TOKEN=mytokenkey
-      - DDNS_IPV4=example.com
-    healthcheck:
-      test: ["CMD", "pgrep", "crond"]
-      interval: 5m
-      timeout: 10s
-      retries: 3
-```
+2. 配置 HTTP 代理 `-e DDNS_PROXY=http://proxy:port`
 
 ## 更多资源
 
-- [DDNS GitHub 主页](https://github.com/NewFuture/DDNS)
-- [Docker Hub - newfuture/ddns](https://hub.docker.com/r/newfuture/ddns)
-- [环境变量配置详情](env.md)
-- [JSON 配置文件详情](json.md)
-- [命令行参数详情](cli.md)
+-   [DDNS GitHub 主页](https://github.com/NewFuture/DDNS)
+-   [Docker Hub - newfuture/ddns](https://hub.docker.com/r/newfuture/ddns)
+-   [环境变量配置详情](env.md)
+-   [JSON 配置文件详情](json.md)
+-   [命令行参数详情](cli.md)

+ 39 - 7
doc/env.md

@@ -108,6 +108,38 @@ DDNS 支持通过环境变量进行配置,环境变量的优先级为:**[命
   export DDNS_DNS="callback"      # 自定义回调
   ```
 
+### 自定义回调配置
+
+当使用 `DDNS_DNS="callback"` 时,可通过以下环境变量配置自定义回调:
+
+- **DDNS_ID**: 回调URL地址,支持变量替换
+- **DDNS_TOKEN**: POST请求参数(JSON字符串),为空时使用GET请求
+
+详细配置请参考:[Callback Provider 配置文档](providers/callback.md)
+
+**示例**:
+
+```bash
+# GET 方式回调
+export DDNS_DNS="callback"
+export DDNS_ID="https://api.example.com/ddns?domain=__DOMAIN__&ip=__IP__"
+export DDNS_TOKEN=""
+
+# POST 方式回调(JSON字符串)
+export DDNS_DNS="callback"
+export DDNS_ID="https://api.example.com/ddns"
+export DDNS_TOKEN='{"api_key": "your_key", "domain": "__DOMAIN__", "ip": "__IP__"}'
+```
+
+**支持的变量替换**:
+
+- `__DOMAIN__`: 完整域名
+- `__IP__`: IP地址(IPv4或IPv6)
+- `__RECORDTYPE__`: DNS记录类型
+- `__TTL__`: 生存时间(秒)
+- `__LINE__`: 解析线路
+- `__TIMESTAMP__`: 当前时间戳
+
 ## 域名配置
 
 ### IPv4 域名列表
@@ -430,16 +462,16 @@ ddns
    - JSON 数组格式:`'["item1", "item2"]'`(推荐)
    - 逗号分隔格式:`"item1,item2"`
 
-2. **配置优先级和字段覆盖关系**: 
-   
+2. **配置优先级和字段覆盖关系**:
+
    DDNS工具中的配置优先级顺序为:**命令行参数 > JSON配置文件 > 环境变量**
-   
+
    - **命令行参数**: 优先级最高,会覆盖JSON配置文件和环境变量中的相同设置
    - **JSON配置文件**: 优先级中等,会覆盖环境变量中的设置,但会被命令行参数覆盖
    - **环境变量**: 优先级最低,当命令行参数和JSON配置文件中都没有相应设置时使用
-   
+
    举例说明:
-   
+
    ```
    # 环境变量设置
    export DDNS_TTL="600"
@@ -452,12 +484,12 @@ ddns
    # 命令行参数
    ddns --ttl 900
    ```
-   
+
    在上述例子中:
    - 最终生效的是命令行参数值:`ttl=900`
    - 如果不提供命令行参数,则使用JSON配置文件值:`ttl=300`
    - 如果JSON配置和命令行参数都不提供,则使用环境变量值:`ttl=600`
-   
+
    另外,JSON配置文件中明确设置为`null`的值会覆盖环境变量设置,相当于未设置该值。
 
 3. **大小写兼容**: 环境变量名支持大写、小写或混合大小写

+ 27 - 24
doc/json.md

@@ -64,34 +64,37 @@ DDNS配置文件遵循JSON模式(Schema),推荐在配置文件中添加`$schem
 
 `index4`和`index6`参数用于指定获取IP地址的方式,可以使用以下值:
 
-- **数字**(如`0`、`1`、`2`...):表示使用第N个网卡的IP地址
-- **字符串**:
-  - `"default"`:系统访问外网的默认IP
-  - `"public"`:使用公网IP(通过API查询)
-  - `"url:xxx"`:通过指定URL获取IP,例如`"url:http://ip.sb"`
-  - `"regex:xxx"`:使用正则表达式匹配本地网络配置中的IP,例如`"regex:192\\.168\\..*"`
-    - 注意:JSON中反斜杠需要转义,如`"regex:10\\.00\\..*"`表示匹配`10.00.`开头的IP
-  - `"cmd:xxx"`:执行指定命令并使用其输出作为IP
-  - `"shell:xxx"`:使用系统shell运行命令并使用其输出作为IP
-- **布尔值**:`false`表示禁止更新相应IP类型的DNS记录
-- **数组**:按顺序尝试不同的获取方式,使用第一个成功获取的结果
+* **数字**(如`0`、`1`、`2`...):表示使用第N个网卡的IP地址
+* **字符串**:
+  * `"default"`:系统访问外网的默认IP
+  * `"public"`:使用公网IP(通过API查询)
+  * `"url:xxx"`:通过指定URL获取IP,例如`"url:http://ip.sb"`
+  * `"regex:xxx"`:使用正则表达式匹配本地网络配置中的IP,例如`"regex:192\\.168\\..*"`
+    * 注意:JSON中反斜杠需要转义,如`"regex:10\\.00\\..*"`表示匹配`10.00.`开头的IP
+  * `"cmd:xxx"`:执行指定命令并使用其输出作为IP
+  * `"shell:xxx"`:使用系统shell运行命令并使用其输出作为IP
+* **布尔值**:`false`表示禁止更新相应IP类型的DNS记录
+* **数组**:按顺序尝试不同的获取方式,使用第一个成功获取的结果
 
 ## 自定义回调配置
 
 当`dns`设置为`callback`时,可通过以下方式配置自定义回调:
 
-- `id`字段:填写回调地址,以HTTP或HTTPS开头
-- `token`字段:POST参数,为空则使用GET方式发起回调
+* `id`字段:填写回调地址,以HTTP或HTTPS开头,支持变量替换
+* `token`字段:POST请求参数(JSON对象或JSON字符串),为空则使用GET方式发起回调
 
-支持的常量替换:
+详细配置请参考:[Callback Provider 配置文档](providers/callback.md)
+
+支持的变量替换:
 
 | 常量名称 | 常量内容 | 说明 |
 |---------|---------|------|
-| `__DOMAIN__` | DDNS域名 | - |
-| `__RECORDTYPE__` | DDNS记录类型 | A或AAAA |
-| `__TTL__` | DDNS TTL | - |
+| `__DOMAIN__` | DDNS域名 | 完整域名 |
+| `__IP__` | 获取的IP地址 | IPv4或IPv6地址 |
+| `__RECORDTYPE__` | DDNS记录类型 | A、AAAA、CNAME等 |
+| `__TTL__` | DDNS TTL | 生存时间(秒) |
+| `__LINE__` | 解析线路 | default、unicom等 |
 | `__TIMESTAMP__` | 请求发起时间戳 | 包含小数 |
-| `__IP__` | 获取的IP地址 | - |
 
 ## 配置示例
 
@@ -167,9 +170,9 @@ DDNS配置文件遵循JSON模式(Schema),推荐在配置文件中添加`$schem
 
 DDNS工具中的配置优先级顺序为:**命令行参数 > JSON配置文件 > 环境变量**。
 
-- **命令行参数**:优先级最高,会覆盖JSON配置文件和环境变量中的相同设置
-- **JSON配置文件**:介于命令行参数和环境变量之间,会覆盖环境变量中的设置
-- **环境变量**:优先级最低,当命令行参数和JSON配置文件中都没有相应设置时使用
+* **命令行参数**:优先级最高,会覆盖JSON配置文件和环境变量中的相同设置
+* **JSON配置文件**:介于命令行参数和环境变量之间,会覆盖环境变量中的设置
+* **环境变量**:优先级最低,当命令行参数和JSON配置文件中都没有相应设置时使用
 
 ### 配置覆盖示例
 
@@ -185,9 +188,9 @@ DDNS工具中的配置优先级顺序为:**命令行参数 > JSON配置文件
 
 ### 特殊情况
 
-- 当JSON配置文件中某个值明确设为`null`时,将覆盖环境变量设置,相当于未设置该值
-- 当JSON配置文件中缺少某个键时,会尝试使用对应的环境变量
-- 某些参数(如`debug`)仅在特定配置方式下有效:`debug`参数只在命令行中有效,JSON配置中的设置会被忽略
+* 当JSON配置文件中某个值明确设为`null`时,将覆盖环境变量设置,相当于未设置该值
+* 当JSON配置文件中缺少某个键时,会尝试使用对应的环境变量
+* 某些参数(如`debug`)仅在特定配置方式下有效:`debug`参数只在命令行中有效,JSON配置中的设置会被忽略
 
 ## 注意事项
 

+ 103 - 0
doc/providers/README.md

@@ -0,0 +1,103 @@
+# DNS Provider 配置指南
+
+本目录包含各个DNS服务商的详细配置指南。DDNS支持多个主流DNS服务商,每个服务商都有其特定的配置要求和API特性。
+
+## 🚀 快速导航
+
+### 有详细配置文档的Provider
+
+| Provider | 服务商 | 配置文档 | 英文文档 | 特点 |
+|----------|--------|----------|----------|------|
+| `dnspod` | [DNSPod 中国版](https://www.dnspod.cn/) | [dnspod.md](dnspod.md) | [dnspod.en.md](dnspod.en.md) | 国内最大DNS服务商 |
+| `alidns` | [阿里云 DNS](https://dns.console.aliyun.com/) | [alidns.md](alidns.md) | [alidns.en.md](alidns.en.md) | 阿里云生态集成 |
+| `tencentcloud` | [腾讯云 DNSPod](https://cloud.tencent.com/product/cns) | [tencentcloud.md](tencentcloud.md) | [tencentcloud.en.md](tencentcloud.en.md) | 腾讯云DNSPod服务 |
+| `callback` | 自定义API (Webhook) | [callback.md](callback.md) | [callback.en.md](callback.en.md) | 自定义HTTP API |
+
+### 其他支持的Provider
+
+| Provider | 服务商 | 官方文档 | 状态 |
+|----------|--------|----------|------|
+| `cloudflare` | [Cloudflare](https://www.cloudflare.com/) | [API文档](https://developers.cloudflare.com/api/) | ⚠️ 缺少充分测试 |
+| `dnscom` | [DNS.COM](https://www.dns.com/) | [API文档](https://www.dns.com/member/apiSet) | ⚠️ 缺少充分测试 |
+| `dnspod_com` | [DNSPod 国际版](https://www.dnspod.com/) | [API文档](https://www.dnspod.com/docs/info.html) | 国际版DNSPod |
+| `he` | [HE.net](https://dns.he.net/) | [DDNS文档](https://dns.he.net/docs.html) | ⚠️ 缺少充分测试,不支持自动创建记录 |
+| `huaweidns` | [华为云 DNS](https://www.huaweicloud.com/product/dns.html) | [API文档](https://support.huaweicloud.com/api-dns/) | ⚠️ 缺少充分测试 |
+
+## ⚙️ 特殊配置说明
+
+### 支持自动创建记录
+
+大部分provider支持自动创建不存在的DNS记录,但有例外:
+
+- ❌ **HE.net**: 不支持自动创建记录,需要手动在控制面板中预先创建
+
+<!-- ## 🔧 域名格式支持
+
+### 标准格式
+
+```text
+subdomain.example.com
+```
+
+### 自定义分隔符格式
+
+支持使用 `~` 或 `+` 分隔子域名和主域名:
+
+```text
+subdomain~example.com
+subdomain+example.com
+``` -->
+
+## 📝 配置示例
+
+### 命令行配置
+
+```bash
+# DNSPod中国版
+ddns --dns dnspod --id 12345 --token your_token --ipv4 example.com
+
+# 阿里云DNS
+ddns --dns alidns --id your_access_key --token your_secret --ipv4 example.com
+
+# Cloudflare (使用邮箱)
+ddns --dns cloudflare --id [email protected] --token your_api_key --ipv4 example.com
+
+# Cloudflare (使用Token)
+ddns --dns cloudflare --token your_api_token --ipv4 example.com
+```
+
+### JSON配置文件
+
+```json
+{
+  "$schema": "https://ddns.newfuture.cc/schema/v4.0.json",
+  "dns": "dnspod",
+  "id": "12345",
+  "token": "your_token_here",
+  "ipv4": ["ddns.example.com", "*.example.com"],
+  "ipv6": ["ddns.example.com"],
+  "ttl": 600
+}
+```
+
+### 环境变量配置
+
+```bash
+export DDNS_DNS=dnspod
+export DDNS_ID=12345
+export DDNS_TOKEN=your_token_here
+export DDNS_IPV4=ddns.example.com
+ddns --debug
+```
+
+## 📚 相关文档
+
+- [命令行配置](../cli.md) - 命令行参数详细说明
+- [JSON配置](../json.md) - JSON配置文件格式说明  
+- [环境变量配置](../env.md) - 环境变量配置方式
+- [Provider开发指南](../dev/provider.md) - 如何开发新的provider
+- [JSON Schema](../../schema/v4.0.json) - 配置文件验证架构
+
+---
+
+如有疑问或需要帮助,请查看 [FAQ](../../README.md#FAQ) 或在 [GitHub Issues](https://github.com/NewFuture/DDNS/issues) 中提问。

+ 180 - 0
doc/providers/alidns.en.md

@@ -0,0 +1,180 @@
+# Alibaba Cloud DNS Configuration Guide
+
+## Overview
+
+Alibaba Cloud DNS (AliDNS) is an authoritative DNS resolution service provided by Alibaba Cloud, supporting high concurrency and high availability domain name resolution. This DDNS project supports authentication through Alibaba Cloud AccessKey.
+
+## Authentication Method
+
+### AccessKey Authentication
+
+Alibaba Cloud DNS uses AccessKey ID and AccessKey Secret for API authentication, which is the standard authentication method for Alibaba Cloud.
+
+#### How to Obtain AccessKey
+
+1. **Login to Alibaba Cloud Console**
+    - Visit [Alibaba Cloud Console](https://ecs.console.aliyun.com/)
+    - Sign in with your Alibaba Cloud account
+
+2. **Go to AccessKey Management**
+    - Visit [AccessKey Management](https://usercenter.console.aliyun.com/#/manage/ak)
+    - Click "Create AccessKey" button
+
+3. **Create New AccessKey**
+    - Click the "Create AccessKey" button
+    - Copy the generated **AccessKey ID** and **AccessKey Secret**
+    - **Important**: Save both values securely
+
+4. **Verify Permissions**
+    - Ensure your account has Alibaba Cloud DNS operation permissions
+    - Check [RAM Access Control](https://ram.console.aliyun.com/policies) if needed
+
+#### Configuration Using AccessKey
+
+```json
+{
+  "dns": "alidns",
+  "id": "LTAI4xxxxxxxxxxxxxxx",
+  "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+}
+```
+
+**Parameters:**
+
+- `id`: Your Alibaba Cloud AccessKey ID
+- `token`: Your Alibaba Cloud AccessKey Secret
+- `dns`: Must be set to `"alidns"`
+
+## Complete Configuration Examples
+
+### Basic Configuration
+
+```json
+{
+  "id": "LTAI4xxxxxxxxxxxxxxx",
+  "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+  "dns": "alidns",
+  "ipv6": ["home.example.com", "server.example.com"]
+}
+```
+
+### Configuration with Optional Parameters
+
+```json
+{
+  "id": "LTAI4xxxxxxxxxxxxxxx",
+  "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+  "dns": "alidns",
+  "ipv6": ["dynamic.mydomain.com"],
+  "ttl": 600,
+  "record_type": "A"
+}
+```
+
+## Optional Configuration Parameters
+
+### TTL (Time To Live)
+
+```json
+{
+  "ttl": 600
+}
+```
+
+- **Range**: 1-86400 seconds
+- **Default**: 600 seconds (10 minutes)
+- **Recommended**: 300-600 seconds for dynamic DNS
+
+### Record Type
+
+```json
+{
+  "record_type": "A"
+}
+```
+
+- **Supported Types**: A, AAAA, CNAME, MX, TXT, SRV, etc.
+- **Default**: A (IPv4)
+- Use "AAAA" for IPv6 addresses
+
+### Resolution Line
+
+```json
+{
+  "line": "default"
+}
+```
+
+- **Options**: "default", "telecom", "unicom", "mobile", "oversea", etc.
+- **Default**: "default"
+- Available line types vary by service plan
+
+## Permission Requirements
+
+Ensure the Alibaba Cloud account has the following permissions:
+
+- **AliyunDNSFullAccess**: Full access to Alibaba Cloud DNS (recommended)
+- **AliyunDNSReadOnlyAccess + Custom Write Permissions**: Fine-grained permission control
+
+You can view and configure permissions in the [RAM Access Control](https://ram.console.aliyun.com/policies).
+
+## Troubleshooting
+
+### Common Issues
+
+#### "Signature Error" or "Authentication Failed"
+
+- Check if AccessKey ID and AccessKey Secret are correct
+- Verify the keys haven't been deleted or disabled
+- Confirm account has sufficient permissions
+
+#### "Domain Does Not Exist"
+
+- Verify the domain is added to Alibaba Cloud DNS
+- Check domain spelling in configuration
+- Ensure domain status is normal
+
+#### "Record Operation Failed"
+
+- Check if subdomain has conflicting records
+- Verify TTL value is within acceptable range
+- Confirm resolution line setting is correct
+- Check if domain plan supports the feature
+
+#### "API Call Limit Exceeded"
+
+- Alibaba Cloud API has QPS limitations
+- Personal Edition: 20 QPS, Enterprise Edition: 50 QPS
+- Increase update intervals appropriately
+
+### Debug Mode
+
+Enable debug logging to see detailed information:
+
+```sh
+ddns --debug
+```
+
+### Common Error Codes
+
+- **InvalidAccessKeyId.NotFound**: AccessKey does not exist
+- **SignatureDoesNotMatch**: Signature error
+- **DomainRecordDuplicate**: Duplicate record
+- **DomainNotExists**: Domain does not exist
+- **Throttling.User**: User requests too frequent
+
+## API Limitations
+
+- **Personal Edition QPS**: 20 requests/second
+- **Enterprise Edition QPS**: 50 requests/second
+- **Domain Count**: Varies by service plan
+- **DNS Records**: Varies by service plan
+
+## Support and Resources
+
+- **Alibaba Cloud DNS Documentation**: <https://help.aliyun.com/product/29697.html>
+- **Alibaba Cloud DNS API Reference**: <https://help.aliyun.com/document_detail/29739.html>
+- **Alibaba Cloud DNS Console**: <https://dns.console.aliyun.com/>
+- **Alibaba Cloud Technical Support**: <https://selfservice.console.aliyun.com/ticket>
+
+> It is recommended to use RAM sub-accounts and grant only the necessary DNS permissions to improve security. Regularly rotate AccessKeys to ensure account security.

+ 167 - 0
doc/providers/alidns.md

@@ -0,0 +1,167 @@
+# 阿里云DNS 配置指南 中文文档
+
+## 概述
+
+阿里云DNS(AliDNS)是阿里云提供的权威DNS解析服务,支持高并发、高可用性的域名解析。本 DDNS 项目支持通过阿里云AccessKey进行认证。
+
+## 认证方式
+
+### AccessKey 认证
+
+阿里云DNS使用AccessKey ID和AccessKey Secret进行API认证,这是阿里云标准的认证方式。
+
+#### 获取AccessKey
+
+1. 登录 [阿里云控制台](https://ecs.console.aliyun.com/)
+2. 访问 [AccessKey管理](https://usercenter.console.aliyun.com/#/manage/ak)
+3. 点击"创建AccessKey"按钮
+4. 复制生成的 **AccessKey ID** 和 **AccessKey Secret**,请妥善保存
+5. 确保账号具有云解析DNS的操作权限
+
+#### 配置示例
+
+```json
+{
+    "dns": "alidns",
+    "id": "LTAI4xxxxxxxxxxxxxxx",
+    "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+}
+```
+
+- `id`:阿里云 AccessKey ID
+- `token`:阿里云 AccessKey Secret
+- `dns`:固定为 `"alidns"`
+
+## 完整配置示例
+
+### 基本配置
+
+```json
+{
+    "id": "LTAI4xxxxxxxxxxxxxxx",
+    "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+    "dns": "alidns",
+    "ipv6": ["home.example.com", "server.example.com"]
+}
+```
+
+### 带可选参数的配置
+
+```json
+{
+    "id": "LTAI4xxxxxxxxxxxxxxx",
+    "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+    "dns": "alidns",
+    "ipv6": ["dynamic.mydomain.com"],
+    "ttl": 600,
+    "record_type": "A"
+}
+```
+
+## 可选参数
+
+### TTL(生存时间)
+
+```json
+{
+    "ttl": 600
+}
+```
+
+- 范围:1-86400 秒
+- 默认:600 秒
+- 推荐:300-600 秒用于动态DNS
+
+### 记录类型
+
+```json
+{
+    "record_type": "A"
+}
+```
+
+- 支持:A、AAAA、CNAME、MX、TXT、SRV等
+- 默认:A(IPv4)
+- IPv6地址使用"AAAA"类型
+
+### 解析线路
+
+```json
+{
+    "line": "default"
+}
+```
+
+- 选项:"default"、"telecom"、"unicom"、"mobile"、"oversea"等
+- 默认:"default"
+- 不同套餐支持的线路类型不同
+
+## 权限要求
+
+确保使用的阿里云账号具有以下权限:
+
+- **AliyunDNSFullAccess**:云解析DNS完全访问权限(推荐)
+- **AliyunDNSReadOnlyAccess + 自定义写权限**:精细化权限控制
+
+可以在 [RAM访问控制](https://ram.console.aliyun.com/policies) 中查看和配置权限。
+
+## 故障排除
+
+### 常见问题
+
+#### "签名错误"或"认证失败"
+
+- 检查AccessKey ID和AccessKey Secret是否正确
+- 确认密钥没有被删除或禁用
+- 验证账号权限是否足够
+
+#### "域名不存在"
+
+- 确认域名已添加到阿里云DNS解析
+- 检查域名拼写是否正确
+- 验证域名状态是否正常
+
+#### "记录操作失败"
+
+- 检查子域名是否存在冲突记录
+- 确认TTL值在合理范围内
+- 验证解析线路设置是否正确
+- 检查域名套餐是否支持该功能
+
+#### "API调用超出限制"
+
+- 阿里云API有QPS限制
+- 个人版:20 QPS,企业版:50 QPS
+- 适当增加更新间隔
+
+### 调试模式
+
+启用调试日志查看详细信息:
+
+```sh
+ddns --debug
+```
+
+### 常见错误代码
+
+- **InvalidAccessKeyId.NotFound**:AccessKey不存在
+- **SignatureDoesNotMatch**:签名错误
+- **DomainRecordDuplicate**:记录重复
+- **DomainNotExists**:域名不存在
+- **Throttling.User**:用户请求过于频繁
+
+## API限制
+
+- **个人版QPS**:20次/秒
+- **企业版QPS**:50次/秒
+- **域名数量**:根据套餐不同
+- **解析记录**:根据套餐不同
+
+## 支持与资源
+
+- [阿里云DNS产品文档](https://help.aliyun.com/product/29697.html)
+- [阿里云DNS API文档](https://help.aliyun.com/document_detail/29739.html)
+- [阿里云DNS控制台](https://dns.console.aliyun.com/)
+- [阿里云技术支持](https://selfservice.console.aliyun.com/ticket)
+
+> 建议使用RAM子账号并仅授予必要的DNS权限,以提高安全性。定期轮换AccessKey以确保账号安全。

+ 257 - 0
doc/providers/callback.en.md

@@ -0,0 +1,257 @@
+# Callback Provider Configuration Guide
+
+The Callback Provider is a universal custom callback interface that allows you to forward DDNS update requests to any custom HTTP API endpoint. This provider is highly flexible, supporting both GET and POST requests with variable substitution functionality.
+
+## Basic Configuration
+
+### Configuration Parameters
+
+| Parameter | Description | Required | Example |
+|-----------|-------------|----------|---------|
+| `id` | Callback URL address with variable substitution support | ✅ | `https://api.example.com/ddns?domain=__DOMAIN__&ip=__IP__` |
+| `token` | POST request parameters (JSON object or JSON string), uses GET when empty | Optional | `{"api_key": "your_key"}` or `"{\"api_key\": \"your_key\"}"` |
+
+### Minimal Configuration Example
+
+```json
+{
+    "id": "https://api.example.com/ddns?domain=__DOMAIN__&ip=__IP__",
+    "token": "",
+    "dns": "callback",
+    "ipv4": ["sub.example.com"],
+    "ipv6": ["ipv6.example.com"]
+}
+```
+
+## Request Methods
+
+### GET Request (Recommended for Simple Scenarios)
+
+When `token` is empty or not set, GET request method is used:
+
+```json
+{
+    "id": "https://api.example.com/update?domain=__DOMAIN__&ip=__IP__&type=__RECORDTYPE__",
+    "token": "",
+    "dns": "callback"
+}
+```
+
+**Actual Request Example:**
+
+```http
+GET https://api.example.com/update?domain=sub.example.com&ip=192.168.1.100&type=A
+```
+
+### POST Request (Recommended for Complex Scenarios)
+
+When `token` is not empty, POST request method is used. `token` can be either a JSON object or JSON string, used as POST request body:
+
+**JSON Object Format:**
+
+```json
+{
+    "id": "https://api.example.com/ddns",
+    "token": {
+        "api_key": "your_secret_key",
+        "domain": "__DOMAIN__",
+        "value": "__IP__",
+        "type": "__RECORDTYPE__",
+        "ttl": "__TTL__"
+    },
+    "dns": "callback"
+}
+```
+
+**JSON String Format:**
+
+```json
+{
+    "id": "https://api.example.com/ddns",
+    "token": "{\"api_key\": \"your_secret_key\", \"domain\": \"__DOMAIN__\", \"value\": \"__IP__\"}",
+    "dns": "callback"
+}
+```
+
+**Actual Request Example:**
+
+```http
+POST https://api.example.com/ddns
+Content-Type: application/json
+
+{
+    "api_key": "your_secret_key",
+    "domain": "sub.example.com",
+    "value": "192.168.1.100",
+    "type": "A",
+    "ttl": "300"
+}
+```
+
+## Variable Substitution
+
+The Callback Provider supports the following built-in variables that are automatically replaced during requests:
+
+| Variable | Description | Example Value |
+|----------|-------------|---------------|
+| `__DOMAIN__` | Full domain name | `sub.example.com` |
+| `__IP__` | IP address (IPv4 or IPv6) | `192.168.1.100` or `2001:db8::1` |
+| `__RECORDTYPE__` | DNS record type | `A`, `AAAA`, `CNAME` |
+| `__TTL__` | Time to live (seconds) | `300`, `600` |
+| `__LINE__` | DNS line/route | `default`, `unicom` |
+| `__TIMESTAMP__` | Current timestamp | `1634567890.123` |
+
+### Variable Substitution Example
+
+**Configuration:**
+
+```json
+{
+    "id": "https://api.example.com/ddns/__DOMAIN__?ip=__IP__&ts=__TIMESTAMP__",
+    "token": {
+        "domain": "__DOMAIN__",
+        "record_type": "__RECORDTYPE__",
+        "ttl": __TTL__,
+        "timestamp": __TIMESTAMP__
+    },
+    "dns": "callback"
+}
+```
+
+**Actual Request:**
+
+```http
+POST https://api.example.com/ddns/sub.example.com?ip=192.168.1.100&ts=1634567890.123
+Content-Type: application/json
+
+{
+    "domain": "sub.example.com",
+    "record_type": "A",
+    "ttl": 300,
+    "timestamp": 1634567890.123
+}
+```
+
+## Use Cases
+
+### 1. Custom Webhook
+
+Send DDNS update notifications to custom webhooks:
+
+```json
+{
+    "id": "https://hooks.example.com/ddns",
+    "token": {
+        "event": "ddns_update",
+        "domain": "__DOMAIN__",
+        "new_ip": "__IP__",
+        "record_type": "__RECORDTYPE__",
+        "timestamp": "__TIMESTAMP__"
+    },
+    "dns": "callback"
+}
+```
+
+### 2. JSON String Format Token
+
+When you need to dynamically construct complex JSON strings:
+
+```json
+{
+    "id": "https://api.example.com/ddns",
+    "token": "{\"auth\": \"your_key\", \"record\": {\"name\": \"__DOMAIN__\", \"value\": \"__IP__\", \"type\": \"__RECORDTYPE__\"}}",
+    "dns": "callback"
+}
+```
+
+## Advanced Configuration
+
+### Error Handling
+
+The Callback Provider logs detailed information:
+
+- **Success**: Logs callback results
+- **Failure**: Logs error information and reasons
+- **Empty Response**: Logs warning messages
+
+### Security Considerations
+
+1. **HTTPS**: Use HTTPS protocol to protect data transmission
+2. **Authentication**: Include necessary authentication information in token
+3. **Validation**: Server should validate request legitimacy
+4. **Logging**: Avoid exposing sensitive information in logs
+
+## Complete Configuration Examples
+
+### GET Method Callback
+
+```json
+{
+    "id": "https://api.example.com/update?key=your_api_key&domain=__DOMAIN__&ip=__IP__&type=__RECORDTYPE__",
+    "token": "",
+    "dns": "callback",
+    "ipv4": ["home.example.com", "server.example.com"],
+    "ipv6": ["ipv6.example.com"],
+    "debug": true
+}
+```
+
+### POST Method Callback (Third-party DNS Integration)
+
+```json
+{
+    "id": "https://api.third-party-dns.com/v1/records",
+    "token": {
+        "auth_token": "your_api_token",
+        "zone": "example.com",
+        "name": "__DOMAIN__",
+        "content": "__IP__",
+        "type": "__RECORDTYPE__",
+        "ttl": "__TTL__"
+    },
+    "dns": "callback",
+    "ipv4": ["*.example.com"],
+    "ipv6": ["*.example.com"],
+    "debug": true
+}
+```
+
+## Troubleshooting
+
+### Common Issues
+
+1. **Invalid URL**: Ensure `id` contains a complete HTTP/HTTPS URL
+2. **JSON Format Error**: Check if `token` JSON format is correct
+   - Object format: `{"key": "value"}`
+   - String format: `"{\"key\": \"value\"}"` (note escaped quotes)
+3. **Variables Not Replaced**: Ensure variable names are spelled correctly (note double underscores)
+4. **Request Failed**: Check if target server is accessible
+5. **Authentication Failed**: Verify API keys or authentication information are correct
+
+### Debugging Methods
+
+1. **Enable Debug**: Set `"debug": true` in configuration
+2. **Check Logs**: Examine detailed information in DDNS runtime logs
+3. **Test API**: Use curl or Postman to test callback API
+4. **Network Check**: Ensure network connectivity and DNS resolution work properly
+
+### Testing Tools
+
+You can use online tools to test callback functionality:
+
+```bash
+# Test GET request with curl
+curl "https://httpbin.org/get?domain=test.example.com&ip=192.168.1.1"
+
+# Test POST request with curl
+curl -X POST "https://httpbin.org/post" \
+  -H "Content-Type: application/json" \
+  -d '{"domain": "test.example.com", "ip": "192.168.1.1"}'
+```
+
+## Related Links
+
+- [DDNS Project Home](../../README.md)
+- [Configuration File Format](../json.md)
+- [Command Line Usage](../cli.md)
+- [Developer Guide](../dev/provider.md)

+ 257 - 0
doc/providers/callback.md

@@ -0,0 +1,257 @@
+# Callback Provider 配置指南
+
+Callback Provider 是一个通用的自定义回调接口,允许您将 DDNS 更新请求转发到任何自定义的 HTTP API 端点或者webhook。这个 Provider 非常灵活,支持 GET 和 POST 请求,并提供变量替换功能。
+
+## 基本配置
+
+### 配置参数
+
+| 参数 | 说明 | 必填 | 示例 |
+|------|------|------|------|
+| `id` | 回调URL地址,支持变量替换 | ✅ | `https://api.example.com/ddns?domain=__DOMAIN__&ip=__IP__` |
+| `token` | POST 请求参数(JSON对象或JSON字符串),为空时使用GET请求 | 可选 | `{"api_key": "your_key"}` 或 `"{\"api_key\": \"your_key\"}"` |
+
+### 最小配置示例
+
+```json
+{
+    "id": "https://api.example.com/ddns?domain=__DOMAIN__&ip=__IP__",
+    "token": "",
+    "dns": "callback",
+    "ipv4": ["sub.example.com"],
+    "ipv6": ["ipv6.example.com"]
+}
+```
+
+## 请求方式
+
+### GET 请求(推荐简单场景)
+
+当 `token` 为空或未设置时,使用 GET 请求方式:
+
+```json
+{
+    "id": "https://api.example.com/update?domain=__DOMAIN__&ip=__IP__&type=__RECORDTYPE__",
+    "token": "",
+    "dns": "callback"
+}
+```
+
+**实际请求示例:**
+
+```http
+GET https://api.example.com/update?domain=sub.example.com&ip=192.168.1.100&type=A
+```
+
+### POST 请求(推荐复杂场景)
+
+当 `token` 不为空时,使用 POST 请求方式。`token` 可以是 JSON 对象或 JSON 字符串,作为 POST 请求体:
+
+**JSON 对象格式:**
+
+```json
+{
+    "id": "https://api.example.com/ddns",
+    "token": {
+        "api_key": "your_secret_key",
+        "domain": "__DOMAIN__",
+        "value": "__IP__",
+        "type": "__RECORDTYPE__",
+        "ttl": "__TTL__"
+    },
+    "dns": "callback"
+}
+```
+
+**JSON 字符串格式:**
+
+```json
+{
+    "id": "https://api.example.com/ddns",
+    "token": "{\"api_key\": \"your_secret_key\", \"domain\": \"__DOMAIN__\", \"value\": \"__IP__\"}",
+    "dns": "callback"
+}
+```
+
+**实际请求示例:**
+
+```http
+POST https://api.example.com/ddns
+Content-Type: application/json
+
+{
+    "api_key": "your_secret_key",
+    "domain": "sub.example.com",
+    "value": "192.168.1.100",
+    "type": "A",
+    "ttl": "300"
+}
+```
+
+## 变量替换
+
+Callback Provider 支持以下内置变量,在请求时会自动替换:
+
+| 变量 | 说明 | 示例值 |
+|------|------|--------|
+| `__DOMAIN__` | 完整域名 | `sub.example.com` |
+| `__IP__` | IP地址(IPv4或IPv6) | `192.168.1.100` 或 `2001:db8::1` |
+| `__RECORDTYPE__` | DNS记录类型 | `A`、`AAAA`、`CNAME` |
+| `__TTL__` | 生存时间(秒) | `300`、`600` |
+| `__LINE__` | 解析线路 | `default`、`unicom` |
+| `__TIMESTAMP__` | 当前时间戳 | `1634567890.123` |
+
+### 变量替换示例
+
+**配置:**
+
+```json
+{
+    "id": "https://api.example.com/ddns/__DOMAIN__?ip=__IP__&ts=__TIMESTAMP__",
+    "token": {
+        "domain": "__DOMAIN__",
+        "record_type": "__RECORDTYPE__",
+        "ttl": "__TTL__",
+        "timestamp": "__TIMESTAMP__"
+    },
+    "dns": "callback"
+}
+```
+
+**实际请求:**
+
+```http
+POST https://api.example.com/ddns/sub.example.com?ip=192.168.1.100&ts=1634567890.123
+Content-Type: application/json
+
+{
+    "domain": "sub.example.com",
+    "record_type": "A",
+    "ttl": 300,
+    "timestamp": 1634567890.123
+}
+```
+
+## 使用场景
+
+### 1. 自定义 Webhook
+
+将 DDNS 更新通知发送到自定义 webhook:
+
+```json
+{
+    "id": "https://hooks.example.com/ddns",
+    "token": {
+        "event": "ddns_update",
+        "domain": "__DOMAIN__",
+        "new_ip": "__IP__",
+        "record_type": "__RECORDTYPE__",
+        "timestamp": "__TIMESTAMP__"
+    },
+    "dns": "callback"
+}
+```
+
+### 2. 使用字符串格式的 token
+
+当需要动态构造复杂的 JSON 字符串时:
+
+```json
+{
+    "id": "https://api.example.com/ddns",
+    "token": "{\"auth\": \"your_key\", \"record\": {\"name\": \"__DOMAIN__\", \"value\": \"__IP__\", \"type\": \"__RECORDTYPE__\"}}",
+    "dns": "callback"
+}
+```
+
+## 高级配置
+
+### 错误处理
+
+Callback Provider 会记录详细的日志信息:
+
+- **成功**:记录回调结果
+- **失败**:记录错误信息和原因
+- **空响应**:记录警告信息
+
+### 安全考虑
+
+1. **HTTPS**: 建议使用 HTTPS 协议保护数据传输
+2. **认证**: 在 token 中包含必要的认证信息
+3. **验证**: 服务端应验证请求的合法性
+4. **日志**: 避免在日志中暴露敏感信息
+
+## 完整配置示例
+
+### GET方式回调
+
+```json
+{
+    "id": "https://api.example.com/update?key=your_api_key&domain=__DOMAIN__&ip=__IP__&type=__RECORDTYPE__",
+    "token": "",
+    "dns": "callback",
+    "ipv4": ["home.example.com", "server.example.com"],
+    "ipv6": ["ipv6.example.com"],
+    "debug": true
+}
+```
+
+### POST方式回调(集成第三方DNS服务)
+
+```json
+{
+    "id": "https://api.third-party-dns.com/v1/records",
+    "token": {
+        "auth_token": "your_api_token",
+        "zone": "example.com",
+        "name": "__DOMAIN__",
+        "content": "__IP__",
+        "type": "__RECORDTYPE__",
+        "ttl": "__TTL__"
+    },
+    "dns": "callback",
+    "ipv4": ["*.example.com"],
+    "ipv6": ["*.example.com"],
+    "debug": true
+}
+```
+
+## 故障排除
+
+### 常见问题
+
+1. **URL无效**: 确保 `id` 包含完整的HTTP/HTTPS URL
+2. **JSON格式错误**: 检查 `token` 的JSON格式是否正确
+   - 对象格式:`{"key": "value"}`
+   - 字符串格式:`"{\"key\": \"value\"}"`(注意转义双引号)
+3. **变量未替换**: 确保变量名拼写正确(注意双下划线)
+4. **请求失败**: 检查目标服务器是否可访问
+5. **认证失败**: 验证API密钥或认证信息是否正确
+
+### 调试方法
+
+1. **启用调试**: 在配置中设置 `"debug": true`
+2. **查看日志**: 检查DDNS运行日志中的详细信息
+3. **测试API**: 使用curl或Postman测试回调API
+4. **网络检查**: 确保网络连通性和DNS解析正常
+
+### 测试工具
+
+可以使用在线工具测试回调功能:
+
+```bash
+# 使用 curl 测试 GET 请求
+curl "https://httpbin.org/get?domain=test.example.com&ip=192.168.1.1"
+
+# 使用 curl 测试 POST 请求
+curl -X POST "https://httpbin.org/post" \
+  -H "Content-Type: application/json" \
+  -d '{"domain": "test.example.com", "ip": "192.168.1.1"}'
+```
+
+## 相关链接
+
+- [DDNS 项目首页](../../README.md)
+- [配置文件格式](../json.md)
+- [命令行使用](../cli.md)
+- [开发者指南](../dev/provider.md)

+ 179 - 0
doc/providers/dnspod.en.md

@@ -0,0 +1,179 @@
+# DDNS Provider English Documentation
+
+## Overview
+
+DNSPod is a DNS service provider under Tencent Cloud, widely used in mainland China. This DDNS project supports connecting to DNSPod via two authentication methods:
+
+1. **API Token** (Recommended)
+2. **Email + Password** (Legacy)
+
+## Authentication Methods
+
+### Method 1: API Token (Recommended)
+
+The API Token method is more secure and is the recommended way to integrate with DNSPod.
+
+#### How to Obtain an API Token
+
+1. **Login to DNSPod Console**
+    - Visit [DNSPod Console](https://console.dnspod.cn/)
+    - Sign in with your DNSPod account
+
+2. **Go to API Key Management**
+    - Click "User Center"
+    - Select the "API Key" menu
+    - Or directly visit: <https://console.dnspod.cn/account/token/token>
+
+3. **Create a New API Key**
+    - Click the "Create Key" button
+    - Enter a descriptive name (e.g., "DDNS Host")
+    - Select appropriate permissions (domain management permission required)
+    - Click "Confirm" to create
+
+4. **Copy Key Information**
+    - **ID**: The key ID (numeric value)
+    - **Token**: The actual key string (long alphanumeric string)
+    - **Important**: Save both values immediately, as the key will only be shown once
+
+#### Configuration Using API Token
+
+```json
+{
+  "dns": "dnspod",
+  "id": "123456",
+  "token": "abcdef1234567890abcdef1234567890"
+}
+```
+
+**Parameters:**
+
+- `id`: Your API Token ID (numeric string)
+- `token`: Your API Token secret
+- `dns`: Must be set to `"dnspod"`
+
+### Method 2: Email + Password (Legacy)
+
+This method uses your DNSPod account email and password. It is still supported but less secure than API Token.
+
+#### How to Use Email Authentication
+
+1. **Ensure Account Availability**
+    - Make sure you can log in to DNSPod with your email and password
+    - Verify your account has domain management permissions
+
+2. **Email and Password Configuration**
+
+```json
+{
+  "id": "[email protected]",
+  "token": "your-account-password",
+  "dns": "dnspod"
+}
+```
+
+**Parameters:**
+
+- `id`: Your DNSPod account email address
+- `token`: Your DNSPod account password
+- `dns`: Must be set to `"dnspod"`
+
+## Complete Configuration Examples
+
+### Example 1: API Token Configuration (Recommended)
+
+```json
+{
+  "id": "123456",
+  "token": "abcdef1234567890abcdef1234567890abcdef12",
+  "dns": "dnspod",
+  "ipv6": ["home.example.com", "nas.example.com"]
+}
+```
+
+### Example 2: Email Authentication Configuration
+
+```json
+{
+  "id": "[email protected]",
+  "token": "mypassword123",
+  "dns": "dnspod",
+  "ipv6": ["dynamic.mydomain.com"]
+}
+```
+
+## Optional Configuration Parameters
+
+### TTL (Time To Live)
+
+```json
+{
+  "ttl": 600
+}
+```
+
+- **Range**: 1-604800 seconds
+- **Default**: 600 seconds (10 minutes)
+- **Recommended**: 120-600 seconds for dynamic DNS
+
+### Record Type
+
+```json
+{
+  "record_type": "A"
+}
+```
+
+- **Supported Types**: A, AAAA, CNAME
+- **Default**: A (IPv4)
+- Use "AAAA" for IPv6 addresses
+
+### Line (ISP Route)
+
+```json
+{
+  "line": "默认"
+}
+```
+
+- **Options**: "默认" (Default), "电信" (China Telecom), "联通" (China Unicom), "移动" (China Mobile), etc.
+- **Default**: "默认" (Default line)
+
+## Troubleshooting
+
+### Common Issues
+
+#### "Authentication Failed" Error
+
+- **API Token**: Check if ID and Token are correct
+- **Email**: Check email and password for typos
+- **Permissions**: Ensure the token/account has domain management permissions
+
+#### "Domain Not Found" Error
+
+- Verify the domain is added to your DNSPod account
+- Check the domain spelling in your configuration
+- Ensure the domain is active and not suspended
+
+#### "Record Creation Failed"
+
+- Check if the subdomain already exists with a different record type
+- Verify TTL value is within the acceptable range
+- Ensure you have permission to modify the specific domain
+
+### Debug Mode
+
+Enable debug logging to troubleshoot issues:
+
+```sh
+ddns --debug
+```
+
+This will display detailed logs for troubleshooting.
+
+## Support and Resources
+
+- **DNSPod Documentation**: <https://docs.dnspod.cn/>
+- **API Reference**: <https://docs.dnspod.cn/api/>
+- [Tencent DNSPod(AccessKey)](./tencentcloud.md)
+
+It is recommended to use the API Token method for better security and easier DDNS configuration management.

+ 150 - 0
doc/providers/dnspod.md

@@ -0,0 +1,150 @@
+# DNSPod 配置指南 中文文档
+
+## 概述
+
+DNSPod 是腾讯云旗下的 DNS 解析服务商,在中国大陆地区广泛使用。本 DDNS 项目支持两种认证方式连接 DNSPod:
+
+1. **API Token**(推荐)
+2. **邮箱 + 密码**(传统)
+
+## 认证方式
+
+### 1. API Token(推荐)
+
+API Token 方式更安全,是 DNSPod 推荐的集成方法。
+
+#### 获取 API Token
+
+1. 登录 [DNSPod 控制台](https://console.dnspod.cn/)
+2. 进入“用户中心” > “API 密钥”或访问 <https://console.dnspod.cn/account/token/token>
+3. 点击“创建密钥”,填写描述,选择域名管理权限,完成创建
+4. 复制 **ID**(数字)和 **Token**(字符串),密钥只显示一次,请妥善保存
+
+#### 配置示例
+
+```json
+{
+    "dns": "dnspod",
+    "id": "123456",
+    "token": "abcdef1234567890abcdef1234567890"
+}
+```
+
+- `id`:API Token ID
+- `token`:API Token 密钥
+- `dns`:固定为 `"dnspod"`
+
+### 2. 邮箱 + 密码(传统)
+
+使用 DNSPod 账号邮箱和密码,安全性较低,仅建议特殊场景使用。
+
+#### 配置示例
+
+```json
+{
+    "id": "[email protected]",
+    "token": "your-account-password",
+    "dns": "dnspod"
+}
+```
+
+- `id`:DNSPod 账号邮箱
+- `token`:DNSPod 账号密码
+- `dns`:固定为 `"dnspod"`
+
+## 完整配置示例
+
+### 示例 1:API Token
+
+```json
+{
+    "id": "123456",
+    "token": "abcdef1234567890abcdef1234567890abcdef12",
+    "dns": "dnspod",
+    "ipv6": ["home.example.com", "nas.example.com"]
+}
+```
+
+### 示例 2:邮箱认证
+
+```json
+{
+    "id": "[email protected]",
+    "token": "mypassword123",
+    "dns": "dnspod",
+    "ipv6": ["dynamic.mydomain.com"]
+}
+```
+
+## 可选参数
+
+### TTL(生存时间)
+
+```json
+{
+    "ttl": 600
+}
+```
+
+- 范围:1-604800 秒
+- 默认:600 秒
+- 推荐:120-600 秒
+
+### 记录类型
+
+```json
+{
+    "record_type": "A"
+}
+```
+
+- 支持:A、AAAA、CNAME
+- 默认:A(IPv4)
+
+### 线路(运营商线路)
+
+```json
+{
+    "line": "默认"
+}
+```
+
+- 选项:"默认"、"电信"、"联通"、"移动"等
+- 默认:"默认"
+
+## 故障排除
+
+### 常见问题
+
+#### “认证失败”
+
+- 检查 API Token 或邮箱/密码是否正确
+- 确认有域名管理权限
+
+#### “域名未找到”
+
+- 域名已添加到 DNSPod 账号
+- 配置拼写无误
+- 域名处于活跃状态
+
+#### “记录创建失败”
+
+- 检查子域名是否有冲突记录
+- TTL 合理
+- 有修改权限
+
+### 调试模式
+
+启用调试日志:
+
+```sh
+ddns --debug
+```
+
+## 支持与资源
+
+- [DNSPod 文档](https://docs.dnspod.cn/)
+- [API 参考](https://docs.dnspod.cn/api/)
+- [腾讯云DNSPod(AccessKey)](./tencentcloud.md)
+
+> 推荐使用 API Token 方式,提升安全性与管理便捷性。

+ 193 - 0
doc/providers/tencentcloud.en.md

@@ -0,0 +1,193 @@
+# Tencent Cloud DNS Configuration Guide
+
+## Overview
+
+Tencent Cloud DNS (TencentCloud DNSPod) is a professional DNS resolution service provided by Tencent Cloud, suitable for users who need high availability and high-performance DNS resolution. This DDNS project supports authentication through Tencent Cloud API keys.
+
+## Authentication Method
+
+### API Key Authentication
+
+Tencent Cloud DNS uses SecretId and SecretKey for API authentication, which is the most secure and recommended authentication method.
+
+#### How to Obtain API Keys
+
+##### From DNSPod
+
+1. **Login to DNSPod Console**
+    - Visit [DNSPod Console](https://console.dnspod.cn/)
+    - Sign in with your DNSPod account
+
+2. **Go to API Key Management**
+    - Visit [API Key Management](https://console.dnspod.cn/account/token)
+
+3. **Create a New Secret Key**
+    - Click the "Create Key" button
+    - Enter a descriptive name (e.g., "DDNS Host")
+    - Select appropriate permissions (domain management permission required)
+    - Click "Confirm" to create
+
+##### From Tencent Cloud
+
+1. **Login to Tencent Cloud Console**
+    - Visit [Tencent Cloud Console](https://console.cloud.tencent.com/)
+    - Sign in with your Tencent Cloud account
+
+2. **Go to API Key Management**
+    - Visit [API Key Management](https://console.cloud.tencent.com/cam/capi)
+    - Click "Create Key" button
+
+3. **Create New API Key**
+    - Click the "Create Key" button
+    - Copy the generated **SecretId** and **SecretKey**
+    - **Important**: Save both values securely, as they provide full access to your account
+
+4. **Verify Permissions**
+    - Ensure your account has DNSPod related permissions
+    - Check [Access Management Console](https://console.cloud.tencent.com/cam/policy) if needed
+
+#### Configuration Using API Keys
+
+```json
+{
+  "dns": "tencentcloud",
+  "id": "AKIDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+  "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+}
+```
+
+**Parameters:**
+
+- `id`: Your Tencent Cloud SecretId
+- `token`: Your Tencent Cloud SecretKey
+- `dns`: Must be set to `"tencentcloud"`
+
+## Complete Configuration Examples
+
+### Basic Configuration
+
+```json
+{
+  "id": "AKIDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+  "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+  "dns": "tencentcloud",
+  "ipv6": ["home.example.com", "server.example.com"]
+}
+```
+
+### Configuration with Optional Parameters
+
+```json
+{
+  "id": "AKIDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+  "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+  "dns": "tencentcloud",
+  "ipv6": ["dynamic.mydomain.com"],
+  "ttl": 300,
+  "record_type": "A"
+}
+```
+
+## Optional Configuration Parameters
+
+### TTL (Time To Live)
+
+```json
+{
+  "ttl": 300
+}
+```
+
+- **Range**: 1-604800 seconds
+- **Default**: 600 seconds (10 minutes)
+- **Recommended**: 120-600 seconds for dynamic DNS
+
+### Record Type
+
+```json
+{
+  "record_type": "A"
+}
+```
+
+- **Supported Types**: A, AAAA, CNAME
+- **Default**: A (IPv4)
+- Use "AAAA" for IPv6 addresses
+
+### Line Type (ISP Route)
+
+```json
+{
+  "line": "默认"
+}
+```
+
+- **Options**: "默认" (Default), "电信" (China Telecom), "联通" (China Unicom), "移动" (China Mobile), "教育网" (Education Network), etc.
+- **Default**: "默认" (Default line)
+
+## Permission Requirements
+
+Ensure the Tencent Cloud account has the following permissions:
+
+- **DNSPod**: Domain resolution management permissions
+- **QcloudDNSPodFullAccess**: Full DNSPod access permission (recommended)
+
+You can view and configure permissions in the [Access Management Console](https://console.cloud.tencent.com/cam/policy).
+
+## Troubleshooting
+
+### Common Issues
+
+#### "Signature Error" or "Authentication Failed"
+
+- Check if SecretId and SecretKey are correct
+- Verify the keys haven't expired
+- Confirm account has sufficient permissions
+
+#### "Domain Not Found" Error
+
+- Verify the domain is added to Tencent Cloud DNSPod
+- Check domain spelling in configuration
+- Ensure domain status is normal
+
+#### "Record Operation Failed"
+
+- Check if subdomain has conflicting records
+- Verify TTL value is within acceptable range
+- Confirm line type setting is correct
+
+#### "API Call Limit Exceeded"
+
+- Tencent Cloud API has rate limiting
+- Increase update intervals appropriately
+- Check if other programs are calling the API simultaneously
+
+### Debug Mode
+
+Enable debug logging to see detailed information:
+
+```sh
+ddns --debug
+```
+
+### Common Error Codes
+
+- **AuthFailure.SignatureExpire**: Signature expired
+- **AuthFailure.SecretIdNotFound**: SecretId does not exist
+- **ResourceNotFound.NoDataOfRecord**: Record does not exist
+- **LimitExceeded.RequestLimitExceeded**: Request frequency exceeded
+
+## API Limitations
+
+- **Request Rate**: Default 20 requests per second
+- **Single Query**: Maximum 3000 records returned
+- **Domain Count**: Limited based on service plan
+
+## Support and Resources
+
+- **Tencent Cloud DNSPod Documentation**: <https://cloud.tencent.com/document/product/1427>
+- **Tencent Cloud DNSPod API Reference**: <https://cloud.tencent.com/document/api/1427>
+- **Tencent Cloud Console**: <https://console.cloud.tencent.com/dnspod>
+- **Tencent Cloud Technical Support**: <https://cloud.tencent.com/document/product/282>
+
+> It is recommended to use sub-account API keys and grant only the necessary DNSPod permissions to improve security.

+ 166 - 0
doc/providers/tencentcloud.md

@@ -0,0 +1,166 @@
+# 腾讯云DNS 配置指南 中文文档
+
+## 概述
+
+腾讯云DNS(TencentCloud DNSPod)是腾讯云提供的专业DNS解析服务,适用于需要高可用性和高性能DNS解析的用户。本 DDNS 项目支持通过腾讯云API密钥进行认证。
+
+## 认证方式
+
+### API 密钥认证
+
+腾讯云DNS使用`SecretId`和`SecretKey`进行API认证,这是最安全和推荐的认证方式。
+
+#### 获取API密钥
+
+##### 从DNSPod获取
+
+1. 登录 [DNSPod控制台](https://console.dnspod.cn/)
+2. 进入“用户中心” > “API密钥”或访问 <https://console.dnspod.cn/account/token>
+
+##### 从腾讯云获取
+
+1. 登录 [腾讯云控制台](https://console.cloud.tencent.com/)
+2. 访问 [API密钥管理](https://console.cloud.tencent.com/cam/capi)
+3. 点击"新建密钥"按钮
+4. 复制生成的 **SecretId** 和 **SecretKey**,请妥善保存
+5. 确保账号具有DNSPod相关权限
+
+#### 配置示例
+
+```json
+{
+    "dns": "tencentcloud",
+    "id": "AKIDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+    "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+}
+```
+
+- `id`:腾讯云 SecretId
+- `token`:腾讯云 SecretKey
+- `dns`:固定为 `"tencentcloud"`
+
+## 完整配置示例
+
+### 基本配置
+
+```json
+{
+    "id": "AKIDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+    "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+    "dns": "tencentcloud",
+    "ipv6": ["home.example.com", "server.example.com"]
+}
+```
+
+### 带可选参数的配置
+
+```json
+{
+    "id": "AKIDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+    "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+    "dns": "tencentcloud",
+    "ipv6": ["dynamic.mydomain.com"],
+    "ttl": 300,
+    "record_type": "A"
+}
+```
+
+## 可选参数
+
+### TTL(生存时间)
+
+```json
+{
+    "ttl": 300
+}
+```
+
+### 记录类型
+
+```json
+{
+    "record_type": "A"
+}
+```
+
+- 支持:A、AAAA、CNAME
+- 默认:A(IPv4)
+- IPv6地址使用"AAAA"类型
+
+### 线路类型
+
+```json
+{
+    "line": "默认"
+}
+```
+
+- 选项:"默认"、"电信"、"联通"、"移动"、"教育网"等
+- 默认:"默认"
+
+## 权限要求
+
+确保使用的腾讯云账号具有以下权限:
+
+- **DNSPod**:域名解析管理权限
+- **QcloudDNSPodFullAccess**:DNSPod完全访问权限(推荐)
+
+可以在 [访问管理控制台](https://console.cloud.tencent.com/cam/policy) 中查看和配置权限。
+
+## 故障排除
+
+### 常见问题
+
+#### "签名错误"或"认证失败"
+
+- 检查SecretId和SecretKey是否正确
+- 确认密钥没有过期
+- 验证账号权限是否足够
+
+#### "域名未找到"
+
+- 确认域名已添加到腾讯云DNSPod
+- 检查域名拼写是否正确
+- 验证域名状态是否正常
+
+#### "记录操作失败"
+
+- 检查子域名是否存在冲突记录
+- 确认TTL值在合理范围内
+- 验证线路类型设置是否正确
+
+#### "API调用超出限制"
+
+- 腾讯云API有调用频率限制
+- 适当增加更新间隔
+- 检查是否有其他程序同时调用API
+
+### 调试模式
+
+启用调试日志查看详细信息:
+
+```sh
+ddns --debug
+```
+
+### 常见错误代码
+
+- **AuthFailure.SignatureExpire**:签名过期
+- **AuthFailure.SecretIdNotFound**:SecretId不存在
+- **ResourceNotFound.NoDataOfRecord**:记录不存在
+- **LimitExceeded.RequestLimitExceeded**:请求频率超限
+
+## API限制
+
+- **请求频率**:默认每秒20次
+- **单次查询**:最多返回3000条记录
+- **域名数量**:根据套餐不同而限制
+
+## 支持与资源
+
+- [腾讯云DNSPod产品文档](https://cloud.tencent.com/document/product/1427)
+- [腾讯云DNSPod API文档](https://cloud.tencent.com/document/api/1427)
+- [腾讯云控制台](https://console.cloud.tencent.com/dnspod)
+- [腾讯云技术支持](https://cloud.tencent.com/document/product/282)
+
+> 建议使用子账号API密钥并仅授予必要的DNSPod权限,以提高安全性。

+ 86 - 23
pyproject.toml

@@ -1,4 +1,3 @@
-
 #[build-system] #remove for python2
 #requires = ["setuptools>=64.0", "wheel", "setuptools_scm"]
 #build-backend = "setuptools.build_meta"
@@ -50,11 +49,15 @@ ddns = "ddns.__main__:main"
 # Optional dependencies
 [project.optional-dependencies]
 dev = [
-#   "pytest>=6.0",
-#    "pytest-cov",
     "black",
     "flake8",
-#    "setuptools_scm"
+    "mock;python_version<'3.3'",  # For Python 2.7 compatibility
+]
+# 可选的 pytest 支持(不是默认测试框架)
+pytest = [
+    "pytest>=6.0",
+    "pytest-cov",
+    "mock;python_version<'3.3'",
 ]
 
 # Setuptools configuration
@@ -74,23 +77,27 @@ version = { attr = "ddns.__version__" }
 # description = { attr = "ddns.__description__" }
 
 
-# 测试配置
-#[tool.pytest.ini_options]
-#testpaths = ["tests"]
-#python_files = ["test_*.py"]
-#python_classes = ["Test*"]
-#python_functions = ["test_*"]
-#addopts = [
-#    "--strict-markers",
-#    "--strict-config",
-#    "--cov=ddns",
-#    "--cov-report=term-missing",
-#]
+# 测试配置 - 使用 unittest 作为默认测试框架
+[tool.unittest]
+start-directory = "tests"
+pattern = "test_*.py"
+# unittest 不需要额外配置,使用内置的 test discovery
+
+# pytest 兼容配置(保持与 pytest 的兼容性)
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+python_classes = ["Test*"]
+python_functions = ["test_*"]
+addopts = ["-v", "--tb=short"]
+# 确保 pytest 可以找到 test_base 模块
+pythonpath = [".", "tests"]
 
 # 代码格式化配置
 [tool.black]
-line-length = 100
-target-version = ['py38']
+line-length = 118
+# py34删除尾部空格兼容py2
+target-version = ['py34', 'py38', 'py311']
 include = '\.py$'
 extend-exclude = '''
 /(
@@ -107,8 +114,64 @@ extend-exclude = '''
 '''
 
 # 类型检查配置
-[tool.mypy]
-python_version = "3.8"
-warn_return_any = true
-warn_unused_configs = true
-disallow_untyped_defs = true
+[tool.pyright]
+typeCheckingMode = "standard"
+autoImportCompletions = true
+autoFormatStrings = true
+completeFunctionParens = true
+supportAllPythonDocuments = true
+importFormat = "relative"
+generateWithTypeAnnotation = true
+diagnosticMode = "workspace"
+indexing = true
+useLibraryCodeForTypes = true
+
+# Coverage configuration (可选,仅在使用 pytest-cov 时需要)
+# 要使用覆盖率报告,需要安装: pip install pytest pytest-cov
+# 然后运行: pytest tests/ --cov=ddns --cov-report=term-missing
+[tool.coverage.run]
+source = ["ddns"]
+omit = [
+    "*/tests/*",
+    "*/test_*",
+    "*/__pycache__/*",
+    "*/.*",
+]
+
+[tool.coverage.report]
+exclude_lines = [
+    "pragma: no cover",
+    "def __repr__",
+    "if self.debug:",
+    "if settings.DEBUG",
+    "raise AssertionError",
+    "raise NotImplementedError",
+    "if 0:",
+    "if __name__ == .__main__.:",
+    "class .*\\bProtocol\\):",
+    "@(abc\\.)?abstractmethod",
+]
+show_missing = true
+precision = 2
+
+# Flake8 configuration
+[tool.flake8]
+max-line-length = 118
+extend-ignore = [
+    "E203",  # whitespace before ':'
+    "E501",  # line too long (handled by black)
+    "W503",  # line break before binary operator
+]
+exclude = [
+    ".git",
+    "__pycache__",
+    "build",
+    "dist",
+    ".eggs",
+    "*.egg-info",
+    ".tox",
+    ".venv",
+]
+per-file-ignores = [
+    "tests/*:F401,F811",  # Allow unused imports and redefined names in tests
+]

+ 1 - 1
run.py

@@ -9,7 +9,7 @@
 # nuitka-project: --product-version=0.0.0
 # nuitka-project: --onefile-tempdir-spec="{TEMP}/{PRODUCT}_{VERSION}"
 # nuitka-project: --no-deployment-flag=self-execution
-# nuitka-project: --company-name="New Future"
+# nuitka-project: --company-name="NewFuture"
 # nuitka-project: --copyright=https://ddns.newfuture.cc
 # nuitka-project: --assume-yes-for-downloads
 # nuitka-project: --python-flag=no_site,no_asserts,no_docstrings,isolated,static_hashes

+ 22 - 5
schema/v4.0.json

@@ -49,7 +49,9 @@
         "dnscom",
         "he",
         "huaweidns",
-        "callback"
+        "callback",
+        "debug",
+        "tencentcloud"
       ]
     },
     "ipv4": {
@@ -199,21 +201,36 @@
           "title": "Log Level",
           "description": "日志级别,如 DEBUG、INFO、WARNING、ERROR、CRITICAL",
           "default": "INFO",
-          "enum": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
+          "enum": [
+            "DEBUG",
+            "INFO",
+            "WARNING",
+            "ERROR",
+            "CRITICAL"
+          ]
         },
         "file": {
-          "type": ["string", "null"],
+          "type": [
+            "string",
+            "null"
+          ],
           "title": "Log Output File",
           "description": "日志输出文件路径,留空或为null时输出到控制台"
         },
         "format": {
-          "type": ["string", "null"],
+          "type": [
+            "string",
+            "null"
+          ],
           "title": "Log Format",
           "description": "日志格式,参考Python logging模块的格式字符串",
           "default": "%(asctime)s %(levelname)s [%(module)s]: %(message)s"
         },
         "datefmt": {
-          "type": ["string", "null"],
+          "type": [
+            "string",
+            "null"
+          ],
           "title": "Date Format",
           "description": "日期时间格式,参考Python time.strftime()的格式字符串",
           "default": "%Y-%m-%dT%H:%M:%S"

+ 110 - 0
tests/README.md

@@ -0,0 +1,110 @@
+# DDNS 测试指南 / DDNS Testing Guide
+
+本文档说明如何运行DDNS项目的测试。**unittest** 是默认的测试框架,因为它是Python内置的,无需额外依赖。
+
+This document explains how to run tests for the DDNS project. **unittest** is the default testing framework as it's built into Python and requires no additional dependencies.
+
+## 快速开始 / Quick Start
+
+### 默认方法 unittest / Default Method (unittest)
+
+```bash
+# 运行所有测试(推荐)/ Run all tests (recommended)
+python -m unittest discover tests -v
+
+# 运行基础测试文件 / Run base test file
+python tests/base_test.py -v  
+
+# 运行特定测试文件 / Run specific test file
+python -m unittest tests.test_provider_he -v
+python -m unittest tests.test_provider_dnspod -v
+
+# 运行特定测试类 / Run specific test class
+python -m unittest tests.test_provider_he.TestHeProvider -v
+
+# 运行特定测试方法 / Run specific test method
+python -m unittest tests.test_provider_he.TestHeProvider.test_init_with_basic_config -v
+```
+
+### 可选:使用 pytest / Optional: Using pytest (Advanced Users)
+
+如果你偏好pytest的特性,需要先安装:
+
+If you prefer pytest features, install it first:
+
+```bash
+# 或者直接安装 / or directly: 
+pip install pytest
+
+# 运行所有测试 / Run all tests
+pytest tests/ -v
+
+# 运行特定测试文件 / Run specific test file
+pytest tests/test_provider_he.py -v
+
+```
+
+## 测试结构 / Test Structure
+
+```
+tests/
+├── __init__.py         # 测试包初始化 / Makes tests a package
+├── base_test.py        # 共享测试工具和基类 / Shared test utilities and base classes
+├── test_provider_*.py  # 各个提供商的测试文件 / Tests for each provider  
+└── README.md           # 本测试指南 / This testing guide
+```
+
+## 测试配置 / Test Configuration
+
+项目同时支持unittest(默认)和pytest测试框架:
+
+The project supports both unittest (default) and pytest testing frameworks:
+
+## 编写测试 / Writing Tests
+
+### 使用基础测试类 / Using the Base Test Class
+
+所有提供商测试都应该继承`BaseProviderTestCase`:
+
+All provider tests should inherit from `BaseProviderTestCase`:
+
+```python
+from base_test import BaseProviderTestCase, unittest, patch, MagicMock
+from ddns.provider.your_provider import YourProvider
+
+class TestYourProvider(BaseProviderTestCase):
+    def setUp(self):
+        super(TestYourProvider, self).setUp()
+        # 提供商特定的设置 / Provider-specific setup
+        
+    def test_your_feature(self):
+        provider = YourProvider(self.auth_id, self.auth_token)
+        # 测试实现 / Test implementation
+```
+
+### 测试命名约定 / Test Naming Convention
+
+- 测试文件 / Test files: `test_provider_*.py`
+- 测试类 / Test classes: `Test*Provider`  
+- 测试方法 / Test methods: `test_*`
+
+## Python版本兼容性 / Python Version Compatibility
+
+测试设计为同时兼容Python 2.7和Python 3.x:
+
+Tests are designed to work with both Python 2.7 and Python 3.x:
+
+- `mock` vs `unittest.mock`的导入处理 / Import handling for `mock` vs `unittest.mock`
+- 字符串类型兼容性 / String type compatibility
+- 异常处理兼容性 / Exception handling compatibility  
+- print语句/函数兼容性 / Print statement/function compatibility
+
+### 常见问题 / Common Issues
+
+1. **导入错误 / Import errors**: 确保从项目根目录运行测试 / Ensure you're running tests from the project root directory
+2. **找不到Mock / Mock not found**: 为Python 2.7安装`mock`包:`pip install mock` / Install `mock` package for Python 2.7: `pip install mock==3.0.5`
+3. **找不到pytest / pytest not found**: 安装pytest:`pip install pytest` / Install pytest: `pip install pytest`
+
+**注意**: 项目已通过修改 `tests/__init__.py` 解决了模块导入路径问题,现在所有运行方式都能正常工作。
+
+**Note**: The project has resolved module import path issues by modifying `tests/__init__.py`, and now all running methods work correctly.

+ 12 - 0
tests/__init__.py

@@ -0,0 +1,12 @@
+# coding=utf-8
+"""
+DDNS Tests Package
+"""
+
+import sys
+import os
+
+# 添加当前目录到 Python 路径,这样就可以直接导入 test_base
+current_dir = os.path.dirname(__file__)
+if current_dir not in sys.path:
+    sys.path.insert(0, current_dir)

+ 42 - 0
tests/base_test.py

@@ -0,0 +1,42 @@
+# coding=utf-8
+"""
+Base test utilities and common imports for all provider tests
+
+@author: Github Copilot
+"""
+
+import unittest
+import sys
+import os
+
+# Add the parent directory to the path so we can import the ddns module
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+try:
+    from unittest.mock import patch, MagicMock
+except ImportError:
+    # Python 2.7 compatibility
+    from mock import patch, MagicMock  # type: ignore
+
+
+class BaseProviderTestCase(unittest.TestCase):
+    """Base test case class with common setup for all provider tests"""
+
+    def setUp(self):
+        """Set up common test fixtures"""
+        self.auth_id = "test_id"
+        self.auth_token = "test_token"
+
+    def assertProviderInitialized(self, provider, expected_auth_id=None, expected_auth_token=None):
+        """Helper method to assert provider is correctly initialized"""
+        self.assertEqual(provider.auth_id, expected_auth_id or self.auth_id)
+        self.assertEqual(provider.auth_token, expected_auth_token or self.auth_token)
+
+    def mock_logger(self, provider):
+        """Helper method to mock provider logger"""
+        provider.logger = MagicMock()
+        return provider.logger
+
+
+# Export commonly used imports for convenience
+__all__ = ["BaseProviderTestCase", "unittest", "patch", "MagicMock"]

+ 521 - 0
tests/test_provider_alidns.py

@@ -0,0 +1,521 @@
+# coding=utf-8
+"""
+Unit tests for AlidnsProvider
+
+@author: Github Copilot
+"""
+
+from base_test import BaseProviderTestCase, unittest, patch
+from ddns.provider.alidns import AlidnsProvider
+
+
+class TestAlidnsProvider(BaseProviderTestCase):
+    """Test cases for AlidnsProvider"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        super(TestAlidnsProvider, self).setUp()
+        self.auth_id = "test_access_key_id"
+        self.auth_token = "test_access_key_secret"
+
+    def test_class_constants(self):
+        """Test AlidnsProvider class constants"""
+        self.assertEqual(AlidnsProvider.API, "https://alidns.aliyuncs.com")
+        self.assertEqual(AlidnsProvider.content_type, "application/x-www-form-urlencoded")
+        self.assertTrue(AlidnsProvider.decode_response)
+
+    def test_init_with_basic_config(self):
+        """Test AlidnsProvider initialization with basic configuration"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+        self.assertEqual(provider.auth_id, self.auth_id)
+        self.assertEqual(provider.auth_token, self.auth_token)
+        self.assertEqual(provider.API, "https://alidns.aliyuncs.com")
+
+    def test_signature_v3_generation(self):
+        """Test _signature_v3 method generates correct v3 signature format"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        # Test headers for v3 signature
+        headers = {
+            "host": "alidns.aliyuncs.com",
+            "content-type": "application/x-www-form-urlencoded",
+            "x-acs-action": "TestAction",
+            "x-acs-content-sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+            "x-acs-date": "2023-01-01T12:00:00Z",
+            "x-acs-signature-nonce": "23456789",
+            "x-acs-version": "2015-01-09",
+        }
+
+        body_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+        authorization = provider._signature_v3("POST", "/", headers, body_hash=body_hash)
+
+        # Verify v3 authorization header format - only test the structure, not exact values
+        self.assertTrue(authorization.startswith("ACS3-HMAC-SHA256 Credential="))
+        self.assertIn("Credential={}".format(self.auth_id), authorization)
+        self.assertIn("SignedHeaders=", authorization)
+        self.assertIn("Signature=", authorization)
+        # Verify all headers are included in signed headers
+        self.assertIn("content-type", authorization)
+        self.assertIn("host", authorization)
+        self.assertIn("x-acs-action", authorization)
+
+    def test_request_basic(self):
+        """Test _request method with basic parameters"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        # Only mock the HTTP call to avoid actual network requests
+        with patch.object(provider, "_http") as mock_http:
+            mock_http.return_value = {"Success": True}
+
+            result = provider._request("TestAction", DomainName="example.com")
+
+            # Verify HTTP call was made with correct method and path
+            mock_http.assert_called_once()
+            call_args = mock_http.call_args
+            self.assertEqual(call_args[0], ("POST", "/"))
+
+            # Verify body and headers are present
+            self.assertIn("body", call_args[1])
+            self.assertIn("headers", call_args[1])
+
+            # Verify headers contain required v3 signature fields
+            headers = call_args[1]["headers"]
+            self.assertIn("Authorization", headers)
+            self.assertIn("x-acs-action", headers)
+            self.assertIn("x-acs-date", headers)
+            self.assertIn("x-acs-version", headers)
+            self.assertEqual(headers["x-acs-action"], "TestAction")
+
+            # Verify body is form-encoded
+            body = call_args[1]["body"]
+            self.assertIn("DomainName=example.com", body)
+
+            self.assertEqual(result, {"Success": True})
+
+    def test_request_filters_none_params(self):
+        """Test _request method filters out None parameters"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        # Only mock the HTTP call
+        with patch.object(provider, "_http") as mock_http:
+            mock_http.return_value = {}
+
+            provider._request("TestAction", DomainName="example.com", TTL=None, Line=None)
+
+            # Verify HTTP call was made
+            mock_http.assert_called_once()
+
+            # Body should not contain None parameters
+            call_args = mock_http.call_args[1]
+            body = call_args.get("body", "")
+            self.assertNotIn("TTL=None", body)
+            self.assertNotIn("Line=None", body)
+            self.assertIn("DomainName=example.com", body)
+
+    def test_split_zone_and_sub_success(self):
+        """Test _split_zone_and_sub method with successful response"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_http") as mock_http:
+            mock_http.return_value = {"DomainName": "example.com", "RR": "sub"}
+
+            main, sub, zone_id = provider._split_zone_and_sub("sub.example.com")
+
+            mock_http.assert_called_once()
+            # Verify GetMainDomainName API was called via headers
+            call_headers = mock_http.call_args[1]["headers"]
+            self.assertEqual(call_headers["x-acs-action"], "GetMainDomainName")
+
+            # Verify the input parameter in body
+            call_body = mock_http.call_args[1]["body"]
+            self.assertIn("InputString=sub.example.com", call_body)
+
+            self.assertEqual(main, "example.com")
+            self.assertEqual(sub, "sub")
+            self.assertEqual(zone_id, "example.com")
+
+    def test_split_zone_and_sub_not_found(self):
+        """Test _split_zone_and_sub method when domain is not found"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_http") as mock_http:
+            mock_http.return_value = {}
+
+            main, sub, zone_id = provider._split_zone_and_sub("notfound.com")
+
+            mock_http.assert_called_once()
+            self.assertIsNone(main)
+            self.assertIsNone(sub)
+            self.assertEqual(zone_id, "notfound.com")
+
+    def test_query_record_success_single(self):
+        """Test _query_record method with single record found"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_http") as mock_http:
+            mock_http.return_value = {
+                "DomainRecords": {
+                    "Record": [
+                        {"RR": "www", "RecordId": "123", "Value": "1.2.3.4", "Type": "A"},
+                    ]
+                }
+            }
+
+            result = provider._query_record("example.com", "www", "example.com", "A", None, {})
+
+            mock_http.assert_called_once()
+            # Verify DescribeSubDomainRecords API was called via headers
+            call_headers = mock_http.call_args[1]["headers"]
+            self.assertEqual(call_headers["x-acs-action"], "DescribeSubDomainRecords")
+
+            # Verify parameters in body
+            call_body = mock_http.call_args[1]["body"]
+            self.assertIn("SubDomain=www.example.com", call_body)
+            self.assertIn("DomainName=example.com", call_body)
+            self.assertIn("Type=A", call_body)
+
+            self.assertIsNotNone(result)
+            if result:  # Type narrowing for mypy
+                self.assertEqual(result["RecordId"], "123")
+                self.assertEqual(result["RR"], "www")
+
+    def test_query_record_not_found(self):
+        """Test _query_record method when no matching record is found"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_http") as mock_http:
+            mock_http.return_value = {"DomainRecords": {"Record": []}}
+
+            result = provider._query_record("example.com", "www", "example.com", "A", None, {})
+
+            self.assertIsNone(result)
+
+    def test_query_record_empty_response(self):
+        """Test _query_record method with empty records response"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_http") as mock_http:
+            mock_http.return_value = {"DomainRecords": {"Record": []}}
+
+            result = provider._query_record("example.com", "www", "example.com", "A", None, {})
+
+            self.assertIsNone(result)
+
+    def test_query_record_with_extra_params(self):
+        """Test _query_record method with extra parameters"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_http") as mock_http:
+            mock_http.return_value = {"DomainRecords": {"Record": []}}
+
+            extra = {"Lang": "en", "Status": "Enable"}
+            provider._query_record("example.com", "www", "example.com", "A", "default", extra)
+
+            mock_http.assert_called_once()
+            # Verify extra parameters are included in the request
+            call_body = mock_http.call_args[1]["body"]
+            self.assertIn("Lang=en", call_body)
+            self.assertIn("Status=Enable", call_body)
+            self.assertIn("Line=default", call_body)
+
+    def test_create_record_success(self):
+        """Test _create_record method with successful creation"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"RecordId": "123456"}
+
+            result = provider._create_record("example.com", "www", "example.com", "1.2.3.4", "A", 300, "default", {})
+
+            mock_request.assert_called_once_with(
+                "AddDomainRecord",
+                DomainName="example.com",
+                RR="www",
+                Value="1.2.3.4",
+                Type="A",
+                TTL=300,
+                Line="default",
+            )
+            self.assertTrue(result)
+
+    def test_create_record_failure(self):
+        """Test _create_record method with failed creation"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"Error": "Invalid domain"}
+
+            result = provider._create_record("example.com", "www", "example.com", "1.2.3.4", "A", None, None, {})
+
+            self.assertFalse(result)
+
+    def test_create_record_with_extra_params(self):
+        """Test _create_record method with extra parameters"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"RecordId": "123456"}
+
+            extra = {"Priority": 10, "Remark": "Test record"}
+            result = provider._create_record(
+                "example.com", "www", "example.com", "1.2.3.4", "A", 300, "default", extra
+            )
+
+            mock_request.assert_called_once_with(
+                "AddDomainRecord",
+                DomainName="example.com",
+                RR="www",
+                Value="1.2.3.4",
+                Type="A",
+                TTL=300,
+                Line="default",
+                Priority=10,
+                Remark="Test record",
+            )
+            self.assertTrue(result)
+
+    def test_update_record_success(self):
+        """Test _update_record method with successful update"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        old_record = {"RecordId": "123456", "RR": "www", "Line": "default"}
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"RecordId": "123456"}
+
+            result = provider._update_record("example.com", old_record, "5.6.7.8", "A", 600, "unicom", {})
+
+            mock_request.assert_called_once_with(
+                "UpdateDomainRecord", RecordId="123456", Value="5.6.7.8", RR="www", Type="A", TTL=600, Line="unicom"
+            )
+            self.assertTrue(result)
+
+    def test_update_record_with_fallback_line(self):
+        """Test _update_record method uses old record's line when line is None"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        old_record = {"RecordId": "123456", "RR": "www", "Line": "default"}
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"RecordId": "123456"}
+
+            result = provider._update_record("example.com", old_record, "5.6.7.8", "A", None, None, {})
+
+            mock_request.assert_called_once_with(
+                "UpdateDomainRecord",
+                RecordId="123456",
+                Value="5.6.7.8",
+                RR="www",
+                Type="A",
+                TTL=None,
+                Line="default",  # Should use old record's line
+            )
+            self.assertTrue(result)
+
+    def test_update_record_failure(self):
+        """Test _update_record method with failed update"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        old_record = {"RecordId": "123456", "RR": "www", "Line": "default"}
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"Error": "Record not found"}
+
+            result = provider._update_record("example.com", old_record, "5.6.7.8", "A", None, None, {})
+
+            self.assertFalse(result)
+
+    def test_update_record_no_changes(self):
+        """Test _update_record method when no changes are detected"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        old_record = {
+            "RecordId": "123456",
+            "RR": "www",
+            "Value": "1.2.3.4",
+            "Type": "A",
+            "TTL": 300,
+            "Line": "default",
+        }
+
+        with patch.object(provider, "_request") as mock_request:
+            # Same value, type, and TTL should skip update
+            result = provider._update_record("example.com", old_record, "1.2.3.4", "A", 300, "default", {})
+
+            # Should return True without making any API calls
+            self.assertTrue(result)
+            mock_request.assert_not_called()
+
+    def test_update_record_with_extra_params(self):
+        """Test _update_record method with extra parameters"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        old_record = {"RecordId": "123456", "RR": "www", "Line": "default"}
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"RecordId": "123456"}
+
+            extra = {"Priority": 20, "Remark": "Updated record"}
+            result = provider._update_record("example.com", old_record, "5.6.7.8", "A", 600, "unicom", extra)
+
+            mock_request.assert_called_once_with(
+                "UpdateDomainRecord",
+                RecordId="123456",
+                Value="5.6.7.8",
+                RR="www",
+                Type="A",
+                TTL=600,
+                Line="unicom",
+                Priority=20,
+                Remark="Updated record",
+            )
+            self.assertTrue(result)
+
+
+class TestAlidnsProviderIntegration(BaseProviderTestCase):
+    """Integration test cases for AlidnsProvider - testing with minimal mocking"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        super(TestAlidnsProviderIntegration, self).setUp()
+        self.auth_id = "test_access_key_id"
+        self.auth_token = "test_access_key_secret"
+
+    def test_full_workflow_create_new_record(self):
+        """Test complete workflow for creating a new record"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        # Mock only the HTTP layer to simulate API responses
+        with patch.object(provider, "_http") as mock_http:
+            # Simulate API responses in order: GetMainDomainName, DescribeSubDomainRecords, AddDomainRecord
+            mock_http.side_effect = [
+                {"DomainName": "example.com", "RR": "www"},  # _split_zone_and_sub response
+                {"DomainRecords": {"Record": []}},  # _query_record response (no existing record)
+                {"RecordId": "123456"},  # _create_record response
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A", 300, "default")
+
+            self.assertTrue(result)
+            # Verify the actual HTTP calls made - should be 3 calls
+            self.assertEqual(mock_http.call_count, 3)
+
+            # Check that proper API actions were called by examining request headers
+            call_actions = [call[1]["headers"]["x-acs-action"] for call in mock_http.call_args_list]
+            self.assertIn("GetMainDomainName", call_actions)
+            self.assertIn("DescribeSubDomainRecords", call_actions)
+            self.assertIn("AddDomainRecord", call_actions)
+
+    def test_full_workflow_update_existing_record(self):
+        """Test complete workflow for updating an existing record"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_http") as mock_http:
+            # Simulate API responses: GetMainDomainName, DescribeSubDomainRecords, UpdateDomainRecord
+            mock_http.side_effect = [
+                {"DomainName": "example.com", "RR": "www"},  # _split_zone_and_sub response
+                {  # _query_record response (existing record found)
+                    "DomainRecords": {
+                        "Record": [
+                            {"RecordId": "123456", "RR": "www", "Value": "5.6.7.8", "Type": "A", "Line": "default"}
+                        ]
+                    }
+                },
+                {"RecordId": "123456"},  # _update_record response
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A", 300, "default")
+
+            self.assertTrue(result)
+            # Verify 3 HTTP calls were made
+            self.assertEqual(mock_http.call_count, 3)
+
+            # Check that UpdateDomainRecord was called
+            call_actions = [call[1]["headers"]["x-acs-action"] for call in mock_http.call_args_list]
+            self.assertIn("UpdateDomainRecord", call_actions)
+
+    def test_full_workflow_zone_not_found(self):
+        """Test complete workflow when zone is not found"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_http") as mock_http:
+            # Simulate API returning empty response for zone query
+            mock_http.return_value = {}
+
+            # Should return False when zone not found
+            result = provider.set_record("www.nonexistent.com", "1.2.3.4", "A")
+            self.assertFalse(result)
+
+    def test_full_workflow_create_failure(self):
+        """Test complete workflow when record creation fails"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_http") as mock_http:
+            # Simulate responses: zone found, no existing record, creation fails
+            mock_http.side_effect = [
+                {"DomainName": "example.com", "RR": "www"},  # _split_zone_and_sub response
+                {"DomainRecords": {"Record": []}},  # _query_record response (no existing record)
+                {"Error": "API error", "Code": "InvalidParameter"},  # _create_record fails
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A")
+
+            self.assertFalse(result)
+
+    def test_full_workflow_update_failure(self):
+        """Test complete workflow when record update fails"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_http") as mock_http:
+            # Simulate responses: zone found, existing record found, update fails
+            mock_http.side_effect = [
+                {"DomainName": "example.com", "RR": "www"},  # _split_zone_and_sub response
+                {  # _query_record response (existing record found)
+                    "DomainRecords": {
+                        "Record": [
+                            {"RecordId": "123456", "RR": "www", "Value": "5.6.7.8", "Type": "A", "Line": "default"}
+                        ]
+                    }
+                },
+                {"Error": "API error", "Code": "InvalidParameter"},  # _update_record fails
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A")
+
+            self.assertFalse(result)
+
+    def test_full_workflow_with_options(self):
+        """Test complete workflow with additional options like ttl and line"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_http") as mock_http:
+            # Simulate successful creation with custom options
+            mock_http.side_effect = [
+                {"DomainName": "example.com", "RR": "www"},  # _split_zone_and_sub response
+                {"DomainRecords": {"Record": []}},  # _query_record response (no existing record)
+                {"RecordId": "123456"},  # _create_record response
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A", 600, "unicom")
+
+            self.assertTrue(result)
+            # Verify that extra parameters are passed through correctly
+            self.assertEqual(mock_http.call_count, 3)
+
+            # Check that the create call contains the correct parameters
+            # Find the AddDomainRecord call (should be the last one)
+            add_record_call = None
+            for call in mock_http.call_args_list:
+                if call[1]["headers"]["x-acs-action"] == "AddDomainRecord":
+                    add_record_call = call
+                    break
+
+            self.assertIsNotNone(add_record_call)
+            if add_record_call:
+                create_body = add_record_call[1]["body"]
+                self.assertIn("TTL=600", create_body)
+                self.assertIn("Line=unicom", create_body)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 179 - 0
tests/test_provider_base.py

@@ -0,0 +1,179 @@
+#!/usr/bin/env python
+# coding=utf-8
+"""
+BaseProvider 单元测试
+支持 Python 2.7 和 Python 3
+"""
+
+from base_test import BaseProviderTestCase, unittest
+from ddns.provider._base import BaseProvider
+
+
+class _TestProvider(BaseProvider):
+    """测试用的具体Provider实现"""
+
+    API = "https://api.example.com"
+
+    def __init__(self, auth_id="test_id", auth_token="test_token_123456789", **options):
+        super(_TestProvider, self).__init__(auth_id, auth_token, **options)
+        self._test_zone_data = {"example.com": "zone123", "test.com": "zone456"}
+        self._test_records = {}
+
+    def _query_zone_id(self, domain):
+        return self._test_zone_data.get(domain)
+
+    def _query_record(self, zone_id, subdomain, main_domain, record_type, line=None, extra=None):
+        key = "{}-{}-{}".format(zone_id, subdomain, record_type)
+        return self._test_records.get(key)
+
+    def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl=None, line=None, extra=None):
+        key = "{}-{}-{}".format(zone_id, subdomain, record_type)
+        self._test_records[key] = {"id": "rec123", "name": subdomain, "value": value, "type": record_type}
+        return True
+
+    def _update_record(self, zone_id, old_record, value, record_type, ttl=None, line=None, extra=None):
+        old_record["value"] = value
+        return True
+
+
+class TestBaseProvider(BaseProviderTestCase):
+    """BaseProvider 测试类"""
+
+    def setUp(self):
+        """测试初始化"""
+        super(TestBaseProvider, self).setUp()
+        self.provider = _TestProvider()
+
+    def test_init_success(self):
+        """测试正常初始化"""
+        provider = _TestProvider("test_id", "test_token")
+        self.assertEqual(provider.auth_id, "test_id")
+        self.assertEqual(provider.auth_token, "test_token")
+        self.assertIsNotNone(provider.logger)
+        self.assertIsNone(provider.proxy)
+        self.assertEqual(provider._zone_map, {})
+
+    def test_validate_missing_id(self):
+        """测试缺少auth_id的验证"""
+        with self.assertRaises(ValueError) as cm:
+            _TestProvider("", "token")
+        self.assertIn("id must be configured", str(cm.exception))
+
+    def test_validate_missing_token(self):
+        """测试缺少auth_token的验证"""
+        with self.assertRaises(ValueError) as cm:
+            _TestProvider("id", "")
+        self.assertIn("token must be configured", str(cm.exception))
+
+    def test_get_zone_id_from_cache(self):
+        """测试从缓存获取zone_id"""
+        self.provider._zone_map["cached.com"] = "cached_zone"
+        zone_id = self.provider.get_zone_id("cached.com")
+        self.assertEqual(zone_id, "cached_zone")
+
+    def test_get_zone_id_query_and_cache(self):
+        """测试查询并缓存zone_id"""
+        zone_id = self.provider.get_zone_id("example.com")
+        self.assertEqual(zone_id, "zone123")
+        self.assertEqual(self.provider._zone_map["example.com"], "zone123")
+
+    def test_set_proxy(self):
+        """测试设置代理"""
+        result = self.provider.set_proxy("http://proxy:8080")
+        self.assertEqual(self.provider.proxy, "http://proxy:8080")
+        self.assertEqual(result, self.provider)  # 测试链式调用
+
+    def test_split_custom_domain_with_tilde(self):
+        """测试用~分隔的自定义域名"""
+        sub, main = BaseProvider._split_custom_domain("www~example.com")
+        self.assertEqual(sub, "www")
+        self.assertEqual(main, "example.com")
+
+    def test_split_custom_domain_with_plus(self):
+        """测试用+分隔的自定义域名"""
+        sub, main = BaseProvider._split_custom_domain("api+test.com")
+        self.assertEqual(sub, "api")
+        self.assertEqual(main, "test.com")
+
+    def test_split_custom_domain_no_separator(self):
+        """测试没有分隔符的域名"""
+        sub, main = BaseProvider._split_custom_domain("example.com")
+        self.assertIsNone(sub)
+        self.assertEqual(main, "example.com")
+
+    def test_join_domain_normal(self):
+        """测试正常合并域名"""
+        domain = BaseProvider._join_domain("www", "example.com")
+        self.assertEqual(domain, "www.example.com")
+
+    def test_join_domain_empty_sub(self):
+        """测试空子域名合并"""
+        domain = BaseProvider._join_domain("", "example.com")
+        self.assertEqual(domain, "example.com")
+
+        domain = BaseProvider._join_domain("@", "example.com")
+        self.assertEqual(domain, "example.com")
+
+    def test_encode_dict(self):
+        """测试编码字典参数"""
+        params = {"key1": "value1", "key2": "value2"}
+        result = BaseProvider._encode(params)
+        # 由于字典顺序可能不同,我们检查包含关系
+        self.assertIn("key1=value1", result)
+        self.assertIn("key2=value2", result)
+
+    def test_encode_none(self):
+        """测试编码None参数"""
+        result = BaseProvider._encode(None)
+        self.assertEqual(result, "")
+
+    def test_quote_basic(self):
+        """测试基本URL编码"""
+        result = BaseProvider._quote("hello world")
+        self.assertEqual(result, "hello%20world")
+
+    def test_mask_sensitive_data_empty(self):
+        """测试空数据打码"""
+        result = self.provider._mask_sensitive_data("")
+        self.assertEqual(result, "")
+
+        result = self.provider._mask_sensitive_data(None)
+        self.assertEqual(result, None)
+
+    def test_mask_sensitive_data_long_token(self):
+        """测试长token的打码"""
+        data = "auth_token=test_token_123456789&other=value"
+        result = self.provider._mask_sensitive_data(data)
+        expected = "auth_token=te***89&other=value"
+        self.assertEqual(result, expected)
+
+    def test_set_record_create(self):
+        """测试创建记录"""
+        result = self.provider.set_record("www~example.com", "1.2.3.4", "A")
+        self.assertTrue(result)
+        # 验证记录是否被创建
+        record = self.provider._query_record("zone123", "www", "example.com", "A", None, {})
+        self.assertIsNotNone(record)
+        if record:  # Type narrowing for mypy
+            self.assertEqual(record["value"], "1.2.3.4")
+
+    def test_set_record_update_existing(self):
+        """测试更新现有记录"""
+        # 先创建一个记录
+        self.provider.set_record("www~example.com", "1.2.3.4", "A")
+        # 再更新它
+        result = self.provider.set_record("www~example.com", "9.8.7.6", "A")
+        self.assertTrue(result)
+        record = self.provider._query_record("zone123", "www", "example.com", "A", None, {})
+        if record:  # Type narrowing for mypy
+            self.assertEqual(record["value"], "9.8.7.6")
+
+    def test_set_record_invalid_domain(self):
+        """测试无效域名"""
+        result = self.provider.set_record("invalid.notfound", "1.2.3.4", "A")
+        self.assertFalse(result)
+
+
+if __name__ == "__main__":
+    # 运行测试
+    unittest.main(verbosity=2)

+ 468 - 0
tests/test_provider_callback.py

@@ -0,0 +1,468 @@
+# coding=utf-8
+"""
+Unit tests for CallbackProvider
+
+@author: GitHub Copilot
+"""
+
+import ssl
+import logging
+from base_test import BaseProviderTestCase, unittest, patch
+from ddns.provider.callback import CallbackProvider
+
+
+class TestCallbackProvider(BaseProviderTestCase):
+    """Test cases for CallbackProvider"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        super(TestCallbackProvider, self).setUp()
+        self.auth_id = "https://example.com/callback?domain=__DOMAIN__&ip=__IP__"
+        self.auth_token = ""  # Use empty string instead of None for auth_token
+
+    def test_init_with_basic_config(self):
+        """Test CallbackProvider initialization with basic configuration"""
+        provider = CallbackProvider(self.auth_id, self.auth_token)
+        self.assertEqual(provider.auth_id, self.auth_id)
+        self.assertEqual(provider.auth_token, self.auth_token)
+        self.assertFalse(provider.decode_response)
+
+    def test_init_with_token_config(self):
+        """Test CallbackProvider initialization with token configuration"""
+        auth_token = '{"api_key": "__DOMAIN__", "value": "__IP__"}'
+        provider = CallbackProvider(self.auth_id, auth_token)
+        self.assertEqual(provider.auth_token, auth_token)
+
+    def test_validate_success(self):
+        """Test _validate method with valid configuration"""
+        provider = CallbackProvider(self.auth_id, self.auth_token)
+        # Should not raise any exception since we have a valid auth_id
+        provider._validate()
+
+    def test_validate_failure_no_id(self):
+        """Test _validate method with missing id"""
+        # _validate is called in __init__, so we need to test it directly
+        with self.assertRaises(ValueError) as cm:
+            CallbackProvider(None, self.auth_token)  # type: ignore
+        self.assertIn("id must be configured", str(cm.exception))
+
+    def test_validate_failure_empty_id(self):
+        """Test _validate method with empty id"""
+        # _validate is called in __init__, so we need to test it directly
+        with self.assertRaises(ValueError) as cm:
+            CallbackProvider("", self.auth_token)
+        self.assertIn("id must be configured", str(cm.exception))
+
+    def test_replace_vars_basic(self):
+        """Test _replace_vars method with basic replacements"""
+        provider = CallbackProvider(self.auth_id, self.auth_token)
+
+        test_str = "Hello __NAME__, your IP is __IP__"
+        mapping = {"__NAME__": "World", "__IP__": "192.168.1.1"}
+
+        result = provider._replace_vars(test_str, mapping)
+        expected = "Hello World, your IP is 192.168.1.1"
+        self.assertEqual(result, expected)
+
+    def test_replace_vars_no_matches(self):
+        """Test _replace_vars method with no matching variables"""
+        provider = CallbackProvider(self.auth_id, self.auth_token)
+
+        test_str = "No variables here"
+        mapping = {"__NAME__": "World"}
+
+        result = provider._replace_vars(test_str, mapping)
+        self.assertEqual(result, test_str)
+
+    def test_replace_vars_partial_matches(self):
+        """Test _replace_vars method with partial matches"""
+        provider = CallbackProvider(self.auth_id, self.auth_token)
+
+        test_str = "__DOMAIN__ and __UNKNOWN__ and __IP__"
+        mapping = {"__DOMAIN__": "example.com", "__IP__": "1.2.3.4"}
+
+        result = provider._replace_vars(test_str, mapping)
+        expected = "example.com and __UNKNOWN__ and 1.2.3.4"
+        self.assertEqual(result, expected)
+
+    def test_replace_vars_empty_string(self):
+        """Test _replace_vars method with empty string"""
+        provider = CallbackProvider(self.auth_id, self.auth_token)
+
+        result = provider._replace_vars("", {"__TEST__": "value"})
+        self.assertEqual(result, "")
+
+    def test_replace_vars_empty_mapping(self):
+        """Test _replace_vars method with empty mapping"""
+        provider = CallbackProvider(self.auth_id, self.auth_token)
+
+        test_str = "__DOMAIN__ test"
+        result = provider._replace_vars(test_str, {})
+        self.assertEqual(result, test_str)
+
+    def test_replace_vars_none_values(self):
+        """Test _replace_vars method with None values (should convert to string)"""
+        provider = CallbackProvider(self.auth_id, self.auth_token)
+
+        test_str = "TTL: __TTL__, Line: __LINE__"
+        mapping = {"__TTL__": None, "__LINE__": None}
+
+        result = provider._replace_vars(test_str, mapping)
+        expected = "TTL: None, Line: None"
+        self.assertEqual(result, expected)
+
+    def test_replace_vars_numeric_values(self):
+        """Test _replace_vars method with numeric values (should convert to string)"""
+        provider = CallbackProvider(self.auth_id, self.auth_token)
+
+        test_str = "Port: __PORT__, TTL: __TTL__"
+        mapping = {"__PORT__": 8080, "__TTL__": 300}
+
+        result = provider._replace_vars(test_str, mapping)
+        expected = "Port: 8080, TTL: 300"
+        self.assertEqual(result, expected)
+
+    @patch("ddns.provider.callback.time")
+    @patch.object(CallbackProvider, "_http")
+    def test_set_record_get_method(self, mock_http, mock_time):
+        """Test set_record method using GET method (no token)"""
+        mock_time.return_value = 1634567890.123
+        mock_http.return_value = "Success"
+
+        provider = CallbackProvider(self.auth_id, None)  # type: ignore
+
+        result = provider.set_record("example.com", "192.168.1.1", "A", 300, "default")
+
+        # Verify the result
+        self.assertTrue(result)
+
+        # Verify _http was called with correct parameters
+        mock_http.assert_called_once()
+        args, kwargs = mock_http.call_args
+        self.assertEqual(args[0], "GET")  # method        # Check that URL contains replaced variables
+        url = args[1]
+        self.assertIn("example.com", url)
+        self.assertIn("192.168.1.1", url)
+
+    @patch("ddns.provider.callback.time")
+    @patch.object(CallbackProvider, "_http")
+    def test_set_record_post_method_dict_token(self, mock_http, mock_time):
+        """Test set_record method using POST method with dict token"""
+        mock_time.return_value = 1634567890.123
+        mock_http.return_value = "Success"
+
+        auth_token = {"api_key": "test_key", "domain": "__DOMAIN__", "ip": "__IP__"}
+        provider = CallbackProvider(self.auth_id, auth_token)  # type: ignore
+
+        result = provider.set_record("example.com", "192.168.1.1", "A", 300, "default")
+
+        # Verify the result
+        self.assertTrue(result)  # Verify _http was called with correct parameters
+        mock_http.assert_called_once()
+        args, kwargs = mock_http.call_args
+        self.assertEqual(args[0], "POST")  # method
+        # URL should be replaced with actual values even for POST
+        url = args[1]
+        self.assertIn("example.com", url)
+        self.assertIn("192.168.1.1", url)
+
+        # Check params were properly replaced
+        params = kwargs["body"]
+        self.assertEqual(params["api_key"], "test_key")
+        self.assertEqual(params["domain"], "example.com")
+        self.assertEqual(params["ip"], "192.168.1.1")
+
+    @patch("ddns.provider.callback.time")
+    @patch.object(CallbackProvider, "_http")
+    def test_set_record_post_method_json_token(self, mock_http, mock_time):
+        """Test set_record method using POST method with JSON string token"""
+        mock_time.return_value = 1634567890.123
+        mock_http.return_value = "Success"
+
+        auth_token = '{"api_key": "test_key", "domain": "__DOMAIN__", "ip": "__IP__"}'
+        provider = CallbackProvider(self.auth_id, auth_token)
+
+        result = provider.set_record("example.com", "192.168.1.1", "A", 300, "default")
+
+        # Verify the result
+        self.assertTrue(result)  # Verify _http was called with correct parameters
+        mock_http.assert_called_once()
+        args, kwargs = mock_http.call_args
+        self.assertEqual(args[0], "POST")  # method
+        # URL should be replaced with actual values even for POST
+        url = args[1]
+        self.assertIn("example.com", url)
+        self.assertIn("192.168.1.1", url)
+
+        # Check params were properly replaced
+        params = kwargs["body"]
+        self.assertEqual(params["api_key"], "test_key")
+        self.assertEqual(params["domain"], "example.com")
+        self.assertEqual(params["ip"], "192.168.1.1")
+
+    @patch("ddns.provider.callback.time")
+    @patch.object(CallbackProvider, "_http")
+    def test_set_record_post_method_mixed_types(self, mock_http, mock_time):
+        """Test set_record method with mixed type values in POST parameters"""
+        mock_time.return_value = 1634567890.123
+        mock_http.return_value = "Success"
+
+        auth_token = {"api_key": 12345, "domain": "__DOMAIN__", "timeout": 30, "enabled": True}
+        provider = CallbackProvider(self.auth_id, auth_token)  # type: ignore
+
+        result = provider.set_record("example.com", "192.168.1.1")
+
+        # Verify the result
+        self.assertTrue(result)
+
+        # Verify _http was called with correct parameters
+        mock_http.assert_called_once()
+        args, kwargs = mock_http.call_args
+        self.assertEqual(args[0], "POST")  # method
+
+        # Check that non-string values were not processed, but string values were replaced
+        params = kwargs["body"]
+        self.assertEqual(params["api_key"], 12345)  # unchanged (not a string)
+        self.assertEqual(params["domain"], "example.com")  # replaced (was a string)
+        self.assertEqual(params["timeout"], 30)  # unchanged (not a string)
+        self.assertEqual(params["enabled"], True)  # unchanged (not a string)
+
+    @patch("ddns.provider.callback.time")
+    @patch.object(CallbackProvider, "_http")
+    def test_set_record_http_failure(self, mock_http, mock_time):
+        """Test set_record method when HTTP request fails"""
+        mock_time.return_value = 1634567890.123
+        mock_http.return_value = None  # Simulate failure
+
+        provider = CallbackProvider(self.auth_id, None)  # type: ignore
+
+        result = provider.set_record("example.com", "192.168.1.1")
+
+        # Verify the result is False on failure
+        self.assertFalse(result)
+
+    @patch("ddns.provider.callback.time")
+    @patch.object(CallbackProvider, "_http")
+    def test_set_record_http_none_response(self, mock_http, mock_time):
+        """Test set_record method with None HTTP response"""
+        mock_time.return_value = 1634567890.123
+        mock_http.return_value = None  # None response
+
+        provider = CallbackProvider(self.auth_id, None)  # type: ignore
+
+        result = provider.set_record("example.com", "192.168.1.1")
+
+        # Empty string is falsy, so result should be False
+        self.assertFalse(result)
+
+    @patch("ddns.provider.callback.jsondecode")
+    def test_json_decode_error_handling(self, mock_jsondecode):
+        """Test handling of JSON decode errors in POST method"""
+        mock_jsondecode.side_effect = ValueError("Invalid JSON")
+
+        auth_token = "invalid json"
+        provider = CallbackProvider(self.auth_id, auth_token)
+
+        # This should raise an exception when trying to decode invalid JSON
+        with self.assertRaises(ValueError):
+            provider.set_record("example.com", "192.168.1.1")
+
+
+class TestCallbackProviderRealIntegration(BaseProviderTestCase):
+    """Real integration tests for CallbackProvider using httpbin.org"""
+
+    def setUp(self):
+        """Set up real test fixtures"""
+        super(TestCallbackProviderRealIntegration, self).setUp()
+        # Use httpbin.org as a stable test server
+        self.real_callback_url = "https://httpbin.org/post"
+
+    def _setup_provider_with_mock_logger(self, provider):
+        """Helper method to setup provider with a mock logger."""
+        mock_logger = self.mock_logger(provider)
+        # Ensure the logger is configured to capture info calls
+        mock_logger.setLevel(logging.INFO)
+        return mock_logger
+
+    def _assert_callback_result_logged(self, mock_logger, *expected_strings):
+        """
+        Helper to assert that 'Callback result: %s' was logged with expected content.
+        """
+        info_calls = mock_logger.info.call_args_list
+        response_logged = False
+        for call in info_calls:
+            if len(call[0]) >= 2 and call[0][0] == "Callback result: %s":
+                response_content = str(call[0][1])
+                if all(expected in response_content for expected in expected_strings):
+                    response_logged = True
+                    break
+        self.assertTrue(
+            response_logged,
+            "Expected logger.info to log 'Callback result' containing: {}".format(", ".join(expected_strings)),
+        )
+
+    def test_real_callback_get_method(self):
+        """Test real callback using GET method with httpbin.org and verify logger calls"""
+        # Use httpbin.org/get endpoint for GET requests
+        auth_id = "https://httpbin.org/get?domain=__DOMAIN__&ip=__IP__&record_type=__RECORDTYPE__"
+        provider = CallbackProvider(auth_id, "")
+
+        # Setup provider with mock logger
+        mock_logger = self._setup_provider_with_mock_logger(provider)
+
+        result = provider.set_record("test.example.com", "111.111.111.111", "A")
+        # httpbin.org returns JSON with our parameters, so it should be truthy
+        self.assertTrue(result)
+
+        # Verify that logger.info was called with response containing domain and IP
+        self._assert_callback_result_logged(mock_logger, "test.example.com", "111.111.111.111")
+
+    def test_real_callback_post_method_with_json(self):
+        """Test real callback using POST method with JSON data and verify logger calls"""
+        auth_id = "https://httpbin.org/post"
+        auth_token = '{"domain": "__DOMAIN__", "ip": "__IP__", "record_type": "__RECORDTYPE__", "ttl": "__TTL__"}'
+        provider = CallbackProvider(auth_id, auth_token)
+
+        # Setup provider with mock logger
+        mock_logger = self._setup_provider_with_mock_logger(provider)
+
+        result = provider.set_record("test.example.com", "203.0.113.2", "A", 300)
+        # httpbin.org returns JSON with our posted data, so it should be truthy
+        self.assertTrue(result)
+
+        # Verify that logger.info was called with response containing domain and IP
+        self._assert_callback_result_logged(mock_logger, "test.example.com", "203.0.113.2")
+
+    def test_real_callback_error_handling(self):
+        """Test real callback error handling with invalid URL"""
+        # Use an invalid URL to test error handling
+        auth_id = "https://httpbin.org/status/500"  # This returns HTTP 500
+        provider = CallbackProvider(auth_id, "")
+
+        result = provider.set_record("test.example.com", "203.0.113.5")
+        self.assertFalse(result)
+
+    def test_real_callback_redirects_handling(self):
+        """Test real callback with HTTP redirects and verify logger calls"""
+        # Use httpbin.org redirect endpoint
+        auth_id = "https://httpbin.org/redirect-to?url=https://httpbin.org/get&domain=__DOMAIN__&ip=__IP__"
+        provider = CallbackProvider(auth_id, "")
+
+        try:
+            # Setup provider with mock logger
+            mock_logger = self._setup_provider_with_mock_logger(provider)
+
+            result = provider.set_record("redirect.test.example.com", "203.0.113.21", "A")
+            # Should follow redirects and succeed
+            self.assertTrue(result)
+
+            # Verify that logger.info was called with response containing domain and IP
+            self._assert_callback_result_logged(mock_logger, "redirect.test.example.com", "203.0.113.21")
+
+        except Exception as e:
+            error_str = str(e).lower()
+            if "certificate verify failed" in error_str and "basic constraints" in error_str:
+                self.skipTest("SSL Basic Constraints issue (common in test environments): {}".format(e))
+            elif "ssl" in error_str or "certificate" in error_str:
+                self.skipTest("SSL-related issue: {}".format(e))
+
+    def test_real_callback_simple_http_endpoint(self):
+        """Test with a simple endpoint that doesn't require special headers and verify logger calls"""
+        # Use a very simple endpoint that usually has good SSL
+        auth_id = "http://httpbin.org/get?domain=__DOMAIN__&ip=__IP__"
+        provider = CallbackProvider(auth_id, "")
+        # Setup provider with mock logger
+        mock_logger = self._setup_provider_with_mock_logger(provider)
+
+        result = provider.set_record("httpstat.test.example.com", "111.111.111.111", "A")
+        # httpstat.us returns simple status messages, should be truthy
+        self.assertTrue(result)
+
+        # Verify that logger.info was called with the successful result
+        self._assert_callback_result_logged(mock_logger, "httpstat.test.example.com", "111.111.111.111")
+
+    def test_real_callback_redirect_following(self):
+        """Test real callback with HTTP redirects using the improved _send_request method and verify logger calls"""
+        # Use httpbin.org redirect endpoint that returns 302
+        auth_id = "https://httpbin.org/redirect-to?url=https://httpbin.org/get&domain=__DOMAIN__&ip=__IP__"
+        provider = CallbackProvider(auth_id, "")
+
+        # Setup provider with mock logger
+        mock_logger = self._setup_provider_with_mock_logger(provider)
+
+        result = provider.set_record("redirect.follow.test.com", "203.0.113.30", "A")
+        self.assertTrue(result)
+
+        # Verify that logger.info was called with the final response after redirection
+        self._assert_callback_result_logged(mock_logger, "redirect.follow.test.com", "203.0.113.30")
+
+    def test_real_callback_multiple_redirects(self):
+        """Test callback with multiple consecutive redirects and verify logger calls"""
+        # Test with 2 consecutive redirects: httpbin.org/redirect/2
+        auth_id = "https://httpbin.org/redirect/2?domain=__DOMAIN__&ip=__IP__"
+        provider = CallbackProvider(auth_id, "")
+
+        try:
+            # Setup provider with mock logger
+            mock_logger = self._setup_provider_with_mock_logger(provider)
+
+            result = provider.set_record("multi-redirect.example.com", "203.0.113.201", "A")
+            # Should follow multiple redirects and succeed
+            self.assertTrue(result)
+
+            # Verify that logger.info was called with response containing domain and IP
+            self._assert_callback_result_logged(mock_logger, "multi-redirect.example.com", "203.0.113.201")
+
+        except Exception as e:
+            error_str = str(e).lower()
+            if "ssl" in error_str or "certificate" in error_str:
+                self.skipTest("SSL certificate issue: {}".format(e))
+
+    def test_real_callback_redirect_with_post(self):
+        """Test POST request redirect behavior (should change to GET after 302) and verify logger calls"""
+        # POST to redirect endpoint - should convert to GET after 302
+        auth_id = "https://httpbin.org/redirect-to?url=https://httpbin.org/get"
+        auth_token = '{"domain": "__DOMAIN__", "ip": "__IP__", "method": "POST->GET"}'
+        provider = CallbackProvider(auth_id, auth_token)
+
+        try:
+            # Setup provider with mock logger
+            mock_logger = self._setup_provider_with_mock_logger(provider)
+
+            result = provider.set_record("post-redirect.example.com", "203.0.113.202", "A")
+            # POST should be redirected as GET and succeed
+            self.assertTrue(result)
+
+            # Verify that logger.info was called with response (domain/IP may be lost in POST->GET redirect)
+            self._assert_callback_result_logged(mock_logger)
+
+        except ssl.SSLError as e:
+            error_str = str(e).lower()
+            if "ssl" in error_str or "certificate" in error_str:
+                self.skipTest("SSL certificate issue: {}".format(e))
+
+    def test_real_callback_absolute_vs_relative_redirects(self):
+        """Test both absolute and relative URL redirects and verify logger calls"""
+        # Test relative redirect (should work with improved _send_request)
+        auth_id = "https://httpbin.org/relative-redirect/1?domain=__DOMAIN__&ip=__IP__"
+        provider = CallbackProvider(auth_id, "")
+
+        try:
+            # Setup provider with mock logger
+            mock_logger = self._setup_provider_with_mock_logger(provider)
+
+            result = provider.set_record("relative-redirect.example.com", "203.0.113.203", "A")
+            # Should handle relative redirects correctly
+            self.assertTrue(result)
+
+            # Verify that logger.info was called with response containing domain and IP
+            self._assert_callback_result_logged(mock_logger, "relative-redirect.example.com", "203.0.113.203")
+
+        except Exception as e:
+            error_str = str(e).lower()
+            if "ssl" in error_str or "certificate" in error_str:
+                self.skipTest("SSL certificate issue: {}".format(e))
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 550 - 0
tests/test_provider_cloudflare.py

@@ -0,0 +1,550 @@
+# coding=utf-8
+"""
+Unit tests for CloudflareProvider
+
+@author: GitHub Copilot
+"""
+
+from base_test import BaseProviderTestCase, unittest, patch
+from ddns.provider.cloudflare import CloudflareProvider
+
+
+class TestCloudflareProvider(BaseProviderTestCase):
+    """Test cases for CloudflareProvider"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        super(TestCloudflareProvider, self).setUp()
+        self.auth_id = "[email protected]"
+        self.auth_token = "test_api_key_or_token"
+
+    def test_class_constants(self):
+        """Test CloudflareProvider class constants"""
+        self.assertEqual(CloudflareProvider.API, "https://api.cloudflare.com")
+        self.assertEqual(CloudflareProvider.content_type, "application/json")
+        self.assertTrue(CloudflareProvider.decode_response)
+
+    def test_init_with_basic_config(self):
+        """Test CloudflareProvider initialization with basic configuration"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+        self.assertEqual(provider.auth_id, self.auth_id)
+        self.assertEqual(provider.auth_token, self.auth_token)
+        self.assertEqual(provider.API, "https://api.cloudflare.com")
+
+    def test_init_with_token_only(self):
+        """Test CloudflareProvider initialization with token only (Bearer auth)"""
+        provider = CloudflareProvider("", self.auth_token)
+        self.assertEqual(provider.auth_id, "")
+        self.assertEqual(provider.auth_token, self.auth_token)
+
+    def test_validate_success_with_email(self):
+        """Test _validate method with valid email"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+        # Should not raise any exception
+        provider._validate()
+
+    def test_validate_success_with_token_only(self):
+        """Test _validate method with token only (no email)"""
+        provider = CloudflareProvider("", self.auth_token)
+        # Should not raise any exception
+        provider._validate()
+
+    def test_validate_failure_no_token(self):
+        """Test _validate method with missing token"""
+        with self.assertRaises(ValueError) as cm:
+            CloudflareProvider(self.auth_id, "")
+        self.assertIn("token must be configured", str(cm.exception))
+
+    def test_validate_failure_invalid_email(self):
+        """Test _validate method with invalid email format"""
+        with self.assertRaises(ValueError) as cm:
+            CloudflareProvider("invalid_email", self.auth_token)
+        self.assertIn("ID must be a valid email or Empty", str(cm.exception))
+
+    def test_request_with_email_auth(self):
+        """Test _request method using email + API key authentication"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_http") as mock_http:
+            mock_http.return_value = {"success": True, "result": {"id": "zone123"}}
+
+            result = provider._request("GET", "/test", param1="value1")
+
+            mock_http.assert_called_once_with(
+                "GET",
+                "/client/v4/zones/test",
+                headers={"X-Auth-Email": self.auth_id, "X-Auth-Key": self.auth_token},
+                params={"param1": "value1"},
+            )
+            self.assertEqual(result, {"id": "zone123"})
+
+    def test_request_with_bearer_auth(self):
+        """Test _request method using Bearer token authentication"""
+        provider = CloudflareProvider("", self.auth_token)
+
+        with patch.object(provider, "_http") as mock_http:
+            mock_http.return_value = {"success": True, "result": {"id": "zone123"}}
+
+            result = provider._request("GET", "/test", param1="value1")
+
+            mock_http.assert_called_once_with(
+                "GET",
+                "/client/v4/zones/test",
+                headers={"Authorization": "Bearer " + self.auth_token},
+                params={"param1": "value1"},
+            )
+            self.assertEqual(result, {"id": "zone123"})
+
+    def test_request_failure(self):
+        """Test _request method with failed response"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_http") as mock_http:
+            mock_http.return_value = {"success": False, "errors": ["Invalid API key"]}
+
+            result = provider._request("GET", "/test")
+
+            self.assertEqual(result, {"success": False, "errors": ["Invalid API key"]})
+
+    def test_request_filters_none_params(self):
+        """Test _request method filters out None parameters"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_http") as mock_http:
+            mock_http.return_value = {"success": True, "result": {}}
+
+            provider._request("GET", "/test", param1="value1", param2=None, param3="value3")
+
+            # Verify None parameters were filtered out
+            call_args = mock_http.call_args
+            params = call_args[1]["params"]
+            self.assertEqual(params, {"param1": "value1", "param3": "value3"})
+
+    def test_query_zone_id_success(self):
+        """Test _query_zone_id method with successful response"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = [
+                {"id": "zone123", "name": "example.com"},
+                {"id": "zone456", "name": "other.com"},
+            ]
+
+            result = provider._query_zone_id("example.com")
+
+            mock_request.assert_called_once_with("GET", "", **{"name.exact": "example.com", "per_page": 50})
+            self.assertEqual(result, "zone123")
+
+    def test_query_zone_id_not_found(self):
+        """Test _query_zone_id method when domain is not found"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = [{"id": "zone456", "name": "other.com"}]
+
+            result = provider._query_zone_id("notfound.com")
+
+            self.assertIsNone(result)
+
+    def test_query_zone_id_empty_response(self):
+        """Test _query_zone_id method with empty response"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = []
+
+            result = provider._query_zone_id("example.com")
+
+            self.assertIsNone(result)
+
+    def test_query_record_success(self):
+        """Test _query_record method with successful response"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_join_domain") as mock_join, patch.object(provider, "_request") as mock_request:
+
+            mock_join.return_value = "www.example.com"
+            mock_request.return_value = [
+                {"id": "rec123", "name": "www.example.com", "type": "A", "content": "1.2.3.4"},
+                {"id": "rec456", "name": "mail.example.com", "type": "A", "content": "5.6.7.8"},
+            ]
+
+            result = provider._query_record(
+                "zone123", "www", "example.com", "A", None, {}
+            )  # type: dict # type: ignore
+
+            mock_join.assert_called_once_with("www", "example.com")
+            params = {"name.exact": "www.example.com"}
+            mock_request.assert_called_once_with("GET", "/zone123/dns_records", type="A", per_page=10000, **params)
+            self.assertEqual(result["id"], "rec123")
+            self.assertEqual(result["name"], "www.example.com")
+
+    def test_query_record_not_found(self):
+        """Test _query_record method when no matching record is found"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_join_domain") as mock_join, patch.object(provider, "_request") as mock_request:
+
+            mock_join.return_value = "www.example.com"
+            mock_request.return_value = [
+                {"id": "rec456", "name": "mail.example.com", "type": "A", "content": "5.6.7.8"}
+            ]
+
+            result = provider._query_record("zone123", "www", "example.com", "A", None, {})
+
+            self.assertIsNone(result)
+
+    def test_query_record_with_proxy_option(self):
+        """Test _query_record method with proxy option in extra parameters"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_join_domain") as mock_join, patch.object(provider, "_request") as mock_request:
+
+            mock_join.return_value = "www.example.com"
+            mock_request.return_value = []
+
+            extra = {"proxied": True}
+            provider._query_record("zone123", "www", "example.com", "A", None, extra)
+
+            mock_request.assert_called_once_with(
+                "GET",
+                "/zone123/dns_records",
+                type="A",
+                per_page=10000,
+                **{"name.exact": "www.example.com", "proxied": True}
+            )
+
+    def test_create_record_success(self):
+        """Test _create_record method with successful creation"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_join_domain") as mock_join, patch.object(provider, "_request") as mock_request:
+
+            mock_join.return_value = "www.example.com"
+            mock_request.return_value = {"id": "rec123", "name": "www.example.com"}
+
+            result = provider._create_record(
+                "zone123", "www", "example.com", "1.2.3.4", "A", 300, None, {}
+            )  # type: dict # type: ignore
+
+            mock_join.assert_called_once_with("www", "example.com")
+            mock_request.assert_called_once_with(
+                "POST",
+                "/zone123/dns_records",
+                name="www.example.com",
+                type="A",
+                content="1.2.3.4",
+                ttl=300,
+                comment=provider.remark,
+            )
+            self.assertTrue(result)
+
+    def test_create_record_failure(self):
+        """Test _create_record method with failed creation"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_join_domain") as mock_join, patch.object(provider, "_request") as mock_request:
+
+            mock_join.return_value = "www.example.com"
+            mock_request.return_value = None  # API request failed
+
+            result = provider._create_record("zone123", "www", "example.com", "1.2.3.4", "A", None, None, {})
+
+            self.assertFalse(result)
+
+    def test_create_record_with_extra_params(self):
+        """Test _create_record method with extra parameters"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_join_domain") as mock_join, patch.object(provider, "_request") as mock_request:
+
+            mock_join.return_value = "www.example.com"
+            mock_request.return_value = {"id": "rec123"}
+
+            extra = {"proxied": True, "comment": "Custom comment", "priority": 10}
+            result = provider._create_record("zone123", "www", "example.com", "1.2.3.4", "A", 300, None, extra)
+
+            mock_request.assert_called_once_with(
+                "POST",
+                "/zone123/dns_records",
+                name="www.example.com",
+                type="A",
+                content="1.2.3.4",
+                ttl=300,
+                proxied=True,
+                comment="Custom comment",
+                priority=10,
+            )
+            self.assertTrue(result)
+
+    def test_update_record_success(self):
+        """Test _update_record method with successful update"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        old_record = {
+            "id": "rec123",
+            "name": "www.example.com",
+            "comment": "Old comment",
+            "proxied": False,
+            "tags": ["tag1"],
+            "settings": {"ttl": 300},
+        }
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"id": "rec123", "content": "5.6.7.8"}
+
+            result = provider._update_record("zone123", old_record, "5.6.7.8", "A", 600, None, {})
+
+            mock_request.assert_called_once_with(
+                "PUT",
+                "/zone123/dns_records/rec123",
+                type="A",
+                name="www.example.com",
+                content="5.6.7.8",
+                ttl=600,
+                comment="Managed by [DDNS v0.0.0](https://ddns.newfuture.cc)",  # Default Remark since extra is empty
+                proxied=False,
+                tags=["tag1"],
+                settings={"ttl": 300},
+            )
+            self.assertTrue(result)
+
+    def test_update_record_failure(self):
+        """Test _update_record method with failed update"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        old_record = {"id": "rec123", "name": "www.example.com"}
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = None  # API request failed
+
+            result = provider._update_record("zone123", old_record, "5.6.7.8", "A", None, None, {})
+
+            self.assertFalse(result)
+
+    def test_update_record_with_extra_params(self):
+        """Test _update_record method with extra parameters overriding defaults"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        old_record = {"id": "rec123", "name": "www.example.com", "comment": "Old comment", "proxied": False}
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"id": "rec123"}
+
+            extra = {"comment": "New comment", "proxied": True, "priority": 20}
+            result = provider._update_record("zone123", old_record, "5.6.7.8", "A", 600, None, extra)
+
+            mock_request.assert_called_once_with(
+                "PUT",
+                "/zone123/dns_records/rec123",
+                type="A",
+                name="www.example.com",
+                content="5.6.7.8",
+                ttl=600,
+                comment="New comment",  # extra.get("comment", self.remark)
+                proxied=False,  # old_record.get("proxied", extra.get("proxied"))
+                priority=20,  # From extra
+                tags=None,
+                settings=None,
+            )
+            self.assertTrue(result)
+
+    def test_update_record_preserves_old_values(self):
+        """Test _update_record method preserves proxied/tags/settings from old record, uses default comment"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        old_record = {
+            "id": "rec123",
+            "name": "www.example.com",
+            "comment": "Preserve this",
+            "proxied": True,
+            "tags": ["important"],
+            "settings": {"ttl": 300},
+        }
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"id": "rec123"}
+
+            # No extra parameters provided
+            result = provider._update_record("zone123", old_record, "5.6.7.8", "A", 600, None, {})
+
+            mock_request.assert_called_once_with(
+                "PUT",
+                "/zone123/dns_records/rec123",
+                type="A",
+                name="www.example.com",
+                content="5.6.7.8",
+                ttl=600,
+                comment="Managed by [DDNS v0.0.0](https://ddns.newfuture.cc)",  # Default Remark
+                proxied=True,  # Preserved from old record
+                tags=["important"],  # Preserved from old record
+                settings={"ttl": 300},  # Preserved from old record
+            )
+            self.assertTrue(result)
+
+
+class TestCloudflareProviderIntegration(BaseProviderTestCase):
+    """Integration test cases for CloudflareProvider - testing with minimal mocking"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        super(TestCloudflareProviderIntegration, self).setUp()
+        self.auth_id = "[email protected]"
+        self.auth_token = "test_api_key"
+
+    def test_full_workflow_create_new_record(self):
+        """Test complete workflow for creating a new record"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        # Mock only the HTTP layer to simulate API responses
+        with patch.object(provider, "_request") as mock_request:
+            # Simulate API responses in order: zone query, record query, record creation
+            mock_request.side_effect = [
+                [{"id": "zone123", "name": "example.com"}],  # _query_zone_id response
+                [],  # _query_record response (no existing record)
+                {"id": "rec123", "name": "www.example.com"},  # _create_record response
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A", 300)
+
+            self.assertTrue(result)
+            # Verify the actual API calls made
+            self.assertEqual(mock_request.call_count, 3)
+            mock_request.assert_any_call("GET", "", **{"name.exact": "example.com", "per_page": 50})
+            mock_request.assert_any_call(
+                "GET", "/zone123/dns_records", type="A", per_page=10000, **{"name.exact": "www.example.com"}
+            )
+            mock_request.assert_any_call(
+                "POST",
+                "/zone123/dns_records",
+                name="www.example.com",
+                type="A",
+                content="1.2.3.4",
+                ttl=300,
+                comment="Managed by [DDNS v0.0.0](https://ddns.newfuture.cc)",
+            )
+
+    def test_full_workflow_update_existing_record(self):
+        """Test complete workflow for updating an existing record"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            # Simulate API responses
+            mock_request.side_effect = [
+                [{"id": "zone123", "name": "example.com"}],  # _query_zone_id response
+                [  # _query_record response (existing record found)
+                    {"id": "rec123", "name": "www.example.com", "type": "A", "content": "5.6.7.8", "proxied": False}
+                ],
+                {"id": "rec123", "content": "1.2.3.4"},  # _update_record response
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A", 300)
+
+            self.assertTrue(result)
+            # Verify the update call was made
+            mock_request.assert_any_call(
+                "PUT",
+                "/zone123/dns_records/rec123",
+                type="A",
+                name="www.example.com",
+                content="1.2.3.4",
+                ttl=300,
+                comment="Managed by [DDNS v0.0.0](https://ddns.newfuture.cc)",
+                proxied=False,
+                tags=None,
+                settings=None,
+            )
+
+    def test_full_workflow_zone_not_found(self):
+        """Test complete workflow when zone is not found"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            # Simulate API returning empty array for zone query
+            mock_request.return_value = []
+
+            result = provider.set_record("www.nonexistent.com", "1.2.3.4", "A")
+            self.assertFalse(result)
+
+    def test_full_workflow_create_failure(self):
+        """Test complete workflow when record creation fails"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            # Simulate responses: zone found, no existing record, creation fails
+            mock_request.side_effect = [
+                [{"id": "zone123", "name": "example.com"}],  # _query_zone_id response
+                [],  # _query_record response (no existing record)
+                None,  # _create_record fails (API returns None)
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A")
+
+            self.assertFalse(result)
+
+    def test_full_workflow_update_failure(self):
+        """Test complete workflow when record update fails"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            # Simulate responses: zone found, existing record found, update fails
+            mock_request.side_effect = [
+                [{"id": "zone123", "name": "example.com"}],  # _query_zone_id response
+                [  # _query_record response (existing record found)
+                    {"id": "rec123", "name": "www.example.com", "type": "A", "content": "5.6.7.8"}
+                ],
+                None,  # _update_record fails (API returns None)
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A")
+
+            self.assertFalse(result)
+
+    def test_full_workflow_with_proxy_options(self):
+        """Test complete workflow with proxy and other Cloudflare-specific options"""
+        provider = CloudflareProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            # Simulate successful creation with custom options
+            mock_request.side_effect = [
+                [{"id": "zone123", "name": "example.com"}],  # _query_zone_id response
+                [],  # _query_record response (no existing record)
+                {"id": "rec123", "name": "www.example.com"},  # _create_record response
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A", 300, None, proxied=True, priority=10)
+
+            self.assertTrue(result)
+            # Verify that extra parameters are passed through correctly
+            mock_request.assert_any_call(
+                "POST",
+                "/zone123/dns_records",
+                name="www.example.com",
+                type="A",
+                content="1.2.3.4",
+                ttl=300,
+                comment="Managed by [DDNS v0.0.0](https://ddns.newfuture.cc)",
+                proxied=True,
+                priority=10,
+            )
+
+    def test_full_workflow_bearer_token_auth(self):
+        """Test complete workflow using Bearer token authentication"""
+        provider = CloudflareProvider("", self.auth_token)  # No email, Bearer token only
+
+        with patch.object(provider, "_request") as mock_request:
+            # Simulate successful workflow
+            mock_request.side_effect = [
+                [{"id": "zone123", "name": "example.com"}],  # _query_zone_id response
+                [],  # _query_record response (no existing record)
+                {"id": "rec123", "name": "www.example.com"},  # _create_record response
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A")
+
+            self.assertTrue(result)
+            # The workflow should work the same regardless of auth method
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 221 - 0
tests/test_provider_debug.py

@@ -0,0 +1,221 @@
+# coding=utf-8
+"""
+Unit tests for DebugProvider
+
+@author: GitHub Copilot
+"""
+
+import sys
+from base_test import BaseProviderTestCase, unittest, patch, MagicMock
+from ddns.provider.debug import DebugProvider
+
+if sys.version_info[0] < 3:
+    from StringIO import StringIO  # 对应 bytes
+else:
+    from io import StringIO  # 对应 unicode, py2.7中也存在
+
+
+class TestDebugProvider(BaseProviderTestCase):
+    """Test cases for DebugProvider"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        super(TestDebugProvider, self).setUp()
+
+    def test_init_with_basic_config(self):
+        """Test DebugProvider initialization with basic configuration"""
+        provider = DebugProvider(self.auth_id, self.auth_token)
+        self.assertEqual(provider.auth_id, self.auth_id)
+        self.assertEqual(provider.auth_token, self.auth_token)
+
+    def test_validate_always_passes(self):
+        """Test _validate method always passes (no validation required)"""
+        provider = DebugProvider(self.auth_id, self.auth_token)
+        # Should not raise any exception
+        provider._validate()
+
+    def test_validate_with_none_values(self):
+        """Test _validate method with None values still passes"""
+        provider = DebugProvider(None, None)  # type: ignore
+        # Should not raise any exception even with None values
+        provider._validate()
+
+    @patch("sys.stdout", new_callable=StringIO)
+    def test_set_record_ipv4(self, mock_stdout):
+        """Test set_record method with IPv4 address"""
+        provider = DebugProvider(self.auth_id, self.auth_token)
+
+        result = provider.set_record("example.com", "192.168.1.1", "A")
+
+        # Verify the result is True
+        self.assertTrue(result)
+
+        # Check that the correct output was printed
+        output = mock_stdout.getvalue()
+        self.assertIn("[IPv4] 192.168.1.1", output)
+
+    @patch("sys.stdout", new_callable=StringIO)
+    def test_set_record_ipv6(self, mock_stdout):
+        """Test set_record method with IPv6 address"""
+        provider = DebugProvider(self.auth_id, self.auth_token)
+
+        result = provider.set_record("example.com", "2001:db8::1", "AAAA")
+
+        # Verify the result is True
+        self.assertTrue(result)
+
+        # Check that the correct output was printed
+        output = mock_stdout.getvalue()
+        self.assertIn("[IPv6] 2001:db8::1", output)
+
+    @patch("sys.stdout", new_callable=StringIO)
+    def test_set_record_other_type(self, mock_stdout):
+        """Test set_record method with other record types (CNAME, etc.)"""
+        provider = DebugProvider(self.auth_id, self.auth_token)
+
+        result = provider.set_record("example.com", "target.example.com", "CNAME")
+
+        # Verify the result is True
+        self.assertTrue(result)
+
+        # Check that the correct output was printed (empty IP type for non-IP records)
+        output = mock_stdout.getvalue()
+        self.assertIn("[CNAME] target.example.com", output)
+
+    @patch("sys.stdout", new_callable=StringIO)
+    def test_set_record_mx_type(self, mock_stdout):
+        """Test set_record method with MX record type"""
+        provider = DebugProvider(self.auth_id, self.auth_token)
+
+        result = provider.set_record("example.com", "mail.example.com", "MX")
+
+        # Verify the result is True
+        self.assertTrue(result)
+
+        # Check that the correct output was printed
+        output = mock_stdout.getvalue()
+        self.assertIn("[MX] mail.example.com", output)
+
+    @patch("sys.stdout", new_callable=StringIO)
+    def test_set_record_with_all_parameters(self, mock_stdout):
+        """Test set_record method with all optional parameters"""
+        provider = DebugProvider(self.auth_id, self.auth_token)
+
+        result = provider.set_record(
+            domain="test.example.com", value="10.0.0.1", record_type="A", ttl=300, line="default", extra_param="test"
+        )
+
+        # Verify the result is True
+        self.assertTrue(result)
+
+        # Check that the correct output was printed
+        output = mock_stdout.getvalue()
+        self.assertIn("[IPv4] 10.0.0.1", output)
+
+    def test_set_record_logger_debug_called(self):
+        """Test that logger.debug is called with correct parameters"""
+        provider = DebugProvider(self.auth_id, self.auth_token)
+
+        # Mock the logger
+        provider.logger = MagicMock()
+
+        with patch("sys.stdout", new_callable=StringIO):
+            result = provider.set_record("example.com", "192.168.1.1", "A", 600, "telecom")
+
+        # Verify the result is True
+        self.assertTrue(result)
+
+        # Verify logger.debug was called with correct parameters
+        provider.logger.debug.assert_called_once_with(
+            "DebugProvider: %s(%s) => %s", "example.com", "A", "192.168.1.1"
+        )
+
+    @patch("sys.stdout", new_callable=StringIO)
+    def test_set_record_multiple_calls(self, mock_stdout):
+        """Test multiple calls to set_record method"""
+        provider = DebugProvider(self.auth_id, self.auth_token)
+
+        # Make multiple calls
+        result1 = provider.set_record("example1.com", "192.168.1.1", "A")
+        result2 = provider.set_record("example2.com", "2001:db8::1", "AAAA")
+        result3 = provider.set_record("example3.com", "target.example.com", "CNAME")
+
+        # Verify all results are True
+        self.assertTrue(result1)
+        self.assertTrue(result2)
+        self.assertTrue(result3)
+
+        # Check that all outputs were printed
+        output = mock_stdout.getvalue()
+        self.assertIn("[IPv4] 192.168.1.1", output)
+        self.assertIn("[IPv6] 2001:db8::1", output)
+        self.assertIn("[CNAME] target.example.com", output)
+
+    @patch("sys.stdout", new_callable=StringIO)
+    def test_set_record_empty_values(self, mock_stdout):
+        """Test set_record method with empty values"""
+        provider = DebugProvider(self.auth_id, self.auth_token)
+
+        result = provider.set_record("", "", "")
+
+        # Verify the result is True
+        self.assertTrue(result)
+
+        # Check that the correct output was printed
+        output = mock_stdout.getvalue()
+        self.assertIn("[] ", output)
+
+    @patch("sys.stdout", new_callable=StringIO)
+    def test_set_record_none_values(self, mock_stdout):
+        """Test set_record method with None values"""
+        provider = DebugProvider(self.auth_id, self.auth_token)
+
+        result = provider.set_record("example.com", "192.168.1.1", None)  # type: ignore
+
+        # Verify the result is True
+        self.assertTrue(result)
+
+        # Check that the correct output was printed (None record_type results in empty IP type)
+        output = mock_stdout.getvalue()
+        self.assertIn("[None] 192.168.1.1", output)
+
+
+class TestDebugProviderIntegration(unittest.TestCase):
+    """Integration tests for DebugProvider"""
+
+    def test_full_workflow_ipv4(self):
+        """Test complete workflow for IPv4 record"""
+        provider = DebugProvider("test_auth_id", "test_auth_token")
+
+        with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
+            result = provider.set_record("test.com", "1.2.3.4", "A", 300, "default")
+
+            self.assertTrue(result)
+            output = mock_stdout.getvalue()
+            self.assertIn("[IPv4] 1.2.3.4", output)
+
+    def test_full_workflow_ipv6(self):
+        """Test complete workflow for IPv6 record"""
+        provider = DebugProvider("test_auth_id", "test_auth_token")
+
+        with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
+            result = provider.set_record("test.com", "::1", "AAAA", 600, "telecom")
+
+            self.assertTrue(result)
+            output = mock_stdout.getvalue()
+            self.assertIn("[IPv6] ::1", output)
+
+    def test_full_workflow_cname(self):
+        """Test complete workflow for CNAME record"""
+        provider = DebugProvider("test_auth_id", "test_auth_token")
+
+        with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
+            result = provider.set_record("www.test.com", "test.com", "CNAME", 3600)
+
+            self.assertTrue(result)
+            output = mock_stdout.getvalue()
+            self.assertIn("[CNAME] test.com", output)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 419 - 0
tests/test_provider_dnscom.py

@@ -0,0 +1,419 @@
+# coding=utf-8
+"""
+Unit tests for DnscomProvider
+
+@author: GitHub Copilot
+"""
+
+from base_test import BaseProviderTestCase, unittest, patch
+from ddns.provider.dnscom import DnscomProvider
+
+
+class TestDnscomProvider(BaseProviderTestCase):
+    """Test cases for DnscomProvider"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        super(TestDnscomProvider, self).setUp()
+        self.auth_id = "test_api_key"
+        self.auth_token = "test_api_secret"
+
+    def test_class_constants(self):
+        """Test DnscomProvider class constants"""
+        self.assertEqual(DnscomProvider.API, "https://www.51dns.com")
+        self.assertEqual(DnscomProvider.content_type, "application/x-www-form-urlencoded")
+        self.assertTrue(DnscomProvider.decode_response)
+
+    def test_init_with_basic_config(self):
+        """Test DnscomProvider initialization with basic configuration"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+        self.assertEqual(provider.auth_id, self.auth_id)
+        self.assertEqual(provider.auth_token, self.auth_token)
+        self.assertEqual(provider.API, "https://www.51dns.com")
+
+    @patch("ddns.provider.dnscom.time")
+    @patch("ddns.provider.dnscom.md5")
+    def test_signature_generation(self, mock_md5, mock_time):
+        """Test _signature method generates correct signature"""
+        # Mock time and hash
+        mock_time.return_value = 1640995200  # Fixed timestamp
+        mock_hash = mock_md5.return_value
+        mock_hash.hexdigest.return_value = "test_hash_value"
+
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        params = {"action": "test", "domain": "example.com"}
+        signed_params = provider._signature(params)
+
+        # Verify standard parameters are added
+        self.assertEqual(signed_params["apiKey"], self.auth_id)
+        self.assertEqual(signed_params["timestamp"], 1640995200)
+        self.assertEqual(signed_params["hash"], "test_hash_value")
+        self.assertIn("action", signed_params)
+        self.assertIn("domain", signed_params)
+
+    def test_signature_filters_none_params(self):
+        """Test _signature method filters out None parameters"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        with patch("ddns.provider.dnscom.time") as mock_time, patch("ddns.provider.dnscom.md5") as mock_md5:
+            # Mock time to return a fixed timestamp
+            mock_time.return_value = 1640995200
+            mock_hash = mock_md5.return_value
+            mock_hash.hexdigest.return_value = "test_hash"
+
+            params = {"action": "test", "domain": "example.com", "ttl": None, "line": None}
+            signed_params = provider._signature(params)
+
+            # Verify None parameters were filtered out
+            self.assertNotIn("ttl", signed_params)
+            self.assertNotIn("line", signed_params)
+            self.assertIn("action", signed_params)
+            self.assertIn("domain", signed_params)
+
+    def test_request_success(self):
+        """Test _request method with successful response"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_signature") as mock_signature, patch.object(provider, "_http") as mock_http:
+
+            mock_signature.return_value = {"apiKey": self.auth_id, "hash": "test_hash"}
+            mock_http.return_value = {"code": 0, "data": {"result": "success"}}
+
+            result = provider._request("test", domain="example.com")
+
+            mock_signature.assert_called_once_with({"domain": "example.com"})
+            mock_http.assert_called_once_with(
+                "POST", "/api/test/", body={"apiKey": self.auth_id, "hash": "test_hash"}
+            )
+            self.assertEqual(result, {"result": "success"})
+
+    def test_request_failure_none_response(self):
+        """Test _request method with None response"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_signature") as mock_signature, patch.object(provider, "_http") as mock_http:
+
+            mock_signature.return_value = {"apiKey": self.auth_id}
+            mock_http.return_value = None
+
+            with self.assertRaises(Exception) as cm:
+                provider._request("test", domain="example.com")
+
+            self.assertIn("response data is none", str(cm.exception))
+
+    def test_request_failure_api_error(self):
+        """Test _request method with API error response"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_signature") as mock_signature, patch.object(provider, "_http") as mock_http:
+
+            mock_signature.return_value = {"apiKey": self.auth_id}
+            mock_http.return_value = {"code": 1, "message": "Invalid API key"}
+
+            with self.assertRaises(Exception) as cm:
+                provider._request("test", domain="example.com")
+
+            self.assertIn("api error: Invalid API key", str(cm.exception))
+
+    def test_query_zone_id_success(self):
+        """Test _query_zone_id method with successful response"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"domainID": "example.com"}
+
+            result = provider._query_zone_id("example.com")
+
+            mock_request.assert_called_once_with("domain/getsingle", domainID="example.com")
+            self.assertEqual(result, "example.com")
+
+    def test_query_zone_id_not_found(self):
+        """Test _query_zone_id method when domain is not found"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = None
+
+            result = provider._query_zone_id("notfound.com")
+
+            mock_request.assert_called_once_with("domain/getsingle", domainID="notfound.com")
+            self.assertIsNone(result)
+
+    def test_query_record_success(self):
+        """Test _query_record method with successful response"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {
+                "data": [
+                    {"record": "www", "type": "A", "recordID": "123", "value": "1.2.3.4"},
+                    {"record": "mail", "type": "A", "recordID": "456", "value": "5.6.7.8"},
+                ]
+            }
+
+            result = provider._query_record("example.com", "www", "example.com", "A", None, {})
+
+            mock_request.assert_called_once_with("record/list", domainID="example.com", host="www", pageSize=500)
+            self.assertIsNotNone(result)
+            if result:  # Type narrowing
+                self.assertEqual(result["recordID"], "123")
+                self.assertEqual(result["record"], "www")
+
+    def test_query_record_with_line(self):
+        """Test _query_record method with line parameter"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {
+                "data": [
+                    {"record": "www", "type": "A", "recordID": "123", "viewID": "1"},
+                    {"record": "www", "type": "A", "recordID": "456", "viewID": "2"},
+                ]
+            }
+
+            result = provider._query_record("example.com", "www", "example.com", "A", "2", {})
+
+            self.assertIsNotNone(result)
+            if result:  # Type narrowing
+                self.assertEqual(result["recordID"], "456")
+                self.assertEqual(result["viewID"], "2")
+
+    def test_query_record_not_found(self):
+        """Test _query_record method when no matching record is found"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {
+                "data": [{"record": "mail", "type": "A", "recordID": "456", "value": "5.6.7.8"}]
+            }
+
+            result = provider._query_record("example.com", "www", "example.com", "A", None, {})
+
+            self.assertIsNone(result)
+
+    def test_query_record_empty_response(self):
+        """Test _query_record method with empty response"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = None
+
+            result = provider._query_record("example.com", "www", "example.com", "A", None, {})
+
+            self.assertIsNone(result)
+
+    def test_create_record_success(self):
+        """Test _create_record method with successful creation"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"recordID": "123456"}
+
+            result = provider._create_record("example.com", "www", "example.com", "1.2.3.4", "A", 300, "1", {})
+
+            mock_request.assert_called_once_with(
+                "record/create",
+                domainID="example.com",
+                value="1.2.3.4",
+                host="www",
+                type="A",
+                TTL=300,
+                viewID="1",
+                remark=provider.remark,
+            )
+            self.assertTrue(result)
+
+    def test_create_record_with_extra_params(self):
+        """Test _create_record method with extra parameters"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"recordID": "123456"}
+
+            extra = {"remark": "Custom remark", "priority": 10}
+            result = provider._create_record("example.com", "www", "example.com", "1.2.3.4", "A", 300, "1", extra)
+
+            mock_request.assert_called_once_with(
+                "record/create",
+                domainID="example.com",
+                value="1.2.3.4",
+                host="www",
+                type="A",
+                TTL=300,
+                viewID="1",
+                remark="Custom remark",
+                priority=10,
+            )
+            self.assertTrue(result)
+
+    def test_create_record_failure(self):
+        """Test _create_record method with failed creation"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"error": "Domain not found"}
+
+            result = provider._create_record("example.com", "www", "example.com", "1.2.3.4", "A", None, None, {})
+
+            self.assertFalse(result)
+
+    def test_update_record_success(self):
+        """Test _update_record method with successful update"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        old_record = {"recordID": "123456", "remark": "Old remark"}
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"success": True}
+
+            result = provider._update_record("example.com", old_record, "5.6.7.8", "A", 600, None, {})
+
+            mock_request.assert_called_once_with(
+                "record/modify", domainID="example.com", recordID="123456", newvalue="5.6.7.8", newTTL=600
+            )
+            self.assertTrue(result)
+
+    def test_update_record_with_extra_params(self):
+        """Test _update_record method with extra parameters"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        old_record = {"recordID": "123456", "remark": "Old remark"}
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"success": True}
+
+            extra = {"remark": "New remark"}
+            result = provider._update_record("example.com", old_record, "5.6.7.8", "A", 600, "1", extra)
+
+            mock_request.assert_called_once_with(
+                "record/modify", domainID="example.com", recordID="123456", newvalue="5.6.7.8", newTTL=600
+            )
+            self.assertTrue(result)
+
+    def test_update_record_failure(self):
+        """Test _update_record method with failed update"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        old_record = {"recordID": "123456"}
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = None
+
+            result = provider._update_record("example.com", old_record, "5.6.7.8", "A", None, None, {})
+
+            self.assertFalse(result)
+
+
+class TestDnscomProviderIntegration(BaseProviderTestCase):
+    """Integration test cases for DnscomProvider - testing with minimal mocking"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        super(TestDnscomProviderIntegration, self).setUp()
+        self.auth_id = "test_api_key"
+        self.auth_token = "test_api_secret"
+
+    def test_full_workflow_create_new_record(self):
+        """Test complete workflow for creating a new record"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        # Mock only the HTTP layer to simulate API responses
+        with patch.object(provider, "_request") as mock_request:
+            # Simulate API responses in order: zone query, record query, record creation
+            mock_request.side_effect = [
+                {"domainID": "example.com"},  # _query_zone_id response
+                {"data": []},  # _query_record response (no existing record)
+                {"recordID": "123456"},  # _create_record response
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A", 300, "1")
+
+            self.assertTrue(result)
+            # Verify the actual API calls made
+            self.assertEqual(mock_request.call_count, 3)
+            mock_request.assert_any_call("domain/getsingle", domainID="example.com")
+            mock_request.assert_any_call("record/list", domainID="example.com", host="www", pageSize=500)
+            mock_request.assert_any_call(
+                "record/create",
+                domainID="example.com",
+                value="1.2.3.4",
+                host="www",
+                type="A",
+                TTL=300,
+                viewID="1",
+                remark="Managed by [DDNS v0.0.0](https://ddns.newfuture.cc)",
+            )
+
+    def test_full_workflow_update_existing_record(self):
+        """Test complete workflow for updating an existing record"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        # Mock only the HTTP layer to simulate raw API responses
+        with patch.object(provider, "_http") as mock_http:
+            # Simulate raw HTTP API responses as they would come from the server
+            mock_http.side_effect = [
+                {"code": 0, "data": {"domainID": "example.com"}},  # domain/getsingle response
+                {
+                    "code": 0,
+                    "data": {  # record/list response
+                        "data": [{"record": "www", "type": "A", "recordID": "123456", "value": "5.6.7.8"}]
+                    },
+                },
+                {"code": 0, "data": {"recordID": "123456", "success": True}},  # record/modify response
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A", 300, "1")
+
+            self.assertTrue(result)
+            # Verify the actual HTTP calls were made (3 calls total)
+            self.assertEqual(mock_http.call_count, 3)
+
+    def test_full_workflow_zone_not_found(self):
+        """Test complete workflow when zone is not found"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            # Simulate API returning None for zone query
+            mock_request.return_value = None
+
+            result = provider.set_record("www.nonexistent.com", "1.2.3.4", "A")
+            self.assertFalse(result)
+
+    def test_full_workflow_create_failure(self):
+        """Test complete workflow when record creation fails"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            # Simulate responses: zone found, no existing record, creation fails
+            mock_request.side_effect = [
+                {"domainID": "example.com"},  # _query_zone_id response
+                {"data": []},  # _query_record response (no existing record)
+                {"error": "Domain not found"},  # _create_record fails
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A")
+
+            self.assertFalse(result)
+
+    def test_full_workflow_update_failure(self):
+        """Test complete workflow when record update fails"""
+        provider = DnscomProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            # Simulate responses: zone found, existing record found, update fails
+            mock_request.side_effect = [
+                {"domainID": "example.com"},  # _query_zone_id response
+                {  # _query_record response (existing record found)
+                    "data": [{"record": "www", "type": "A", "recordID": "123456", "value": "5.6.7.8"}]
+                },
+                None,  # _update_record fails
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A")
+
+            self.assertFalse(result)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 510 - 0
tests/test_provider_dnspod.py

@@ -0,0 +1,510 @@
+# coding=utf-8
+"""
+DNSPod Provider 单元测试
+支持 Python 2.7 和 Python 3
+"""
+
+from base_test import BaseProviderTestCase, unittest, patch, MagicMock
+from ddns.provider.dnspod import DnspodProvider
+
+
+class TestDnspodProvider(BaseProviderTestCase):
+    """DNSPod Provider 测试类"""
+
+    def setUp(self):
+        """测试初始化"""
+        super(TestDnspodProvider, self).setUp()
+        self.provider = DnspodProvider(self.auth_id, self.auth_token)
+
+    def test_init_with_basic_config(self):
+        """Test DnspodProvider initialization with basic configuration"""
+        provider = DnspodProvider(self.auth_id, self.auth_token)
+        self.assertEqual(provider.auth_id, self.auth_id)
+        self.assertEqual(provider.auth_token, self.auth_token)
+        self.assertEqual(provider.API, "https://dnsapi.cn")
+        self.assertEqual(provider.DefaultLine, "默认")
+
+    def test_class_constants(self):
+        """Test DnspodProvider class constants"""
+        self.assertEqual(DnspodProvider.API, "https://dnsapi.cn")
+        self.assertEqual(DnspodProvider.DefaultLine, "默认")
+        # ContentType should be TYPE_FORM
+        from ddns.provider._base import TYPE_FORM
+
+        self.assertEqual(DnspodProvider.content_type, TYPE_FORM)
+
+    @patch("ddns.provider.dnspod.DnspodProvider._http")
+    def test_request_success(self, mock_http):
+        """Test _request method with successful response"""
+        mock_response = {"status": {"code": "1", "message": "Success"}, "data": {"test": "value"}}
+        mock_http.return_value = mock_response
+
+        result = self.provider._request("Test.Action", test_param="test_value")
+
+        self.assertEqual(result, mock_response)
+        mock_http.assert_called_once()
+
+        # Verify request parameters
+        call_args = mock_http.call_args
+        self.assertEqual(call_args[0][0], "POST")  # Method
+        self.assertEqual(call_args[0][1], "/Test.Action")  # URL
+
+        # Verify body contains login token and format
+        body = call_args[1]["body"]
+        self.assertIn("login_token", body)
+        expected_token = "{0},{1}".format(self.auth_id, self.auth_token)
+        self.assertEqual(body["login_token"], expected_token)
+        self.assertEqual(body["format"], "json")
+        self.assertEqual(body["test_param"], "test_value")
+
+        # Verify headers
+        headers = call_args[1]["headers"]
+        self.assertIn("User-Agent", headers)
+        self.assertIn("DDNS", headers["User-Agent"])
+
+    @patch("ddns.provider.dnspod.DnspodProvider._http")
+    def test_request_failure(self, mock_http):
+        """Test _request method with failed response"""
+        mock_response = {"status": {"code": "0", "message": "API Error"}}
+        mock_http.return_value = mock_response
+
+        # Mock logger to capture warning
+        self.provider.logger = MagicMock()
+
+        result = self.provider._request("Test.Action")
+
+        self.assertEqual(result, mock_response)
+        self.provider.logger.warning.assert_called_once()
+
+    @patch("ddns.provider.dnspod.DnspodProvider._http")
+    def test_request_filters_none_params(self, mock_http):
+        """Test _request method filters out None parameters"""
+        mock_response = {"status": {"code": "1"}}
+        mock_http.return_value = mock_response
+
+        self.provider._request("Test.Action", param1="value1", param2=None, param3="value3")
+
+        body = mock_http.call_args[1]["body"]
+        self.assertEqual(body["param1"], "value1")
+        self.assertEqual(body["param3"], "value3")
+        self.assertNotIn("param2", body)
+
+    @patch("ddns.provider.dnspod.DnspodProvider._http")
+    def test_request_with_extra_params(self, mock_http):
+        """Test _request method with extra parameters"""
+        mock_response = {"status": {"code": "1"}}
+        mock_http.return_value = mock_response
+
+        extra = {"extra_param": "extra_value"}
+        self.provider._request("Test.Action", extra=extra, normal_param="normal_value")
+
+        # Verify both extra and normal params are included
+        body = mock_http.call_args[1]["body"]
+        self.assertEqual(body["extra_param"], "extra_value")
+        self.assertEqual(body["normal_param"], "normal_value")
+
+    @patch("ddns.provider.dnspod.DnspodProvider._http")
+    def test_query_zone_id_success(self, mock_http):
+        """Test _query_zone_id method with successful response"""
+        mock_http.return_value = {"domain": {"id": "12345", "name": "example.com"}}
+
+        zone_id = self.provider._query_zone_id("example.com")
+
+        self.assertEqual(zone_id, "12345")
+        mock_http.assert_called_once()
+        # Verify the action was correct
+        call_args = mock_http.call_args
+        self.assertEqual(call_args[0][1], "/Domain.Info")
+
+    @patch("ddns.provider.dnspod.DnspodProvider._http")
+    def test_query_zone_id_not_found(self, mock_http):
+        """Test _query_zone_id method when domain is not found"""
+        mock_http.return_value = {}
+
+        zone_id = self.provider._query_zone_id("notfound.com")
+
+        self.assertIsNone(zone_id)
+
+    @patch("ddns.provider.dnspod.DnspodProvider._request")
+    def test_query_record_success_single(self, mock_request):
+        """Test _query_record method with single record found"""
+        mock_request.return_value = {"records": [{"id": "123", "name": "www", "value": "192.168.1.1", "type": "A"}]}
+
+        record = self.provider._query_record("zone123", "www", "example.com", "A", None, {})
+
+        self.assertIsNotNone(record)
+        if record:
+            self.assertEqual(record["id"], "123")
+            self.assertEqual(record["name"], "www")
+        mock_request.assert_called_once_with(
+            "Record.List", domain_id="zone123", sub_domain="www", record_type="A", line=None
+        )
+
+    @patch("ddns.provider.dnspod.DnspodProvider._request")
+    def test_query_record_success_multiple(self, mock_request):
+        """Test _query_record method with multiple records found"""
+        mock_request.return_value = {
+            "records": [
+                {"id": "123", "name": "www", "value": "192.168.1.1", "type": "A"},
+                {"id": "124", "name": "ftp", "value": "192.168.1.2", "type": "A"},
+            ]
+        }
+
+        # Mock logger
+        self.provider.logger = MagicMock()
+
+        record = self.provider._query_record("zone123", "www", "example.com", "A", None, {})
+
+        self.assertIsNotNone(record)
+        self.assertEqual(record["name"], "www")  # type: ignore[unreachable]
+        # Should log warning for multiple records
+        self.provider.logger.warning.assert_called_once()
+
+    @patch("ddns.provider.dnspod.DnspodProvider._request")
+    def test_query_record_not_found(self, mock_request):
+        """Test _query_record method when no records found"""
+        mock_request.return_value = {"records": []}
+
+        # Mock logger
+        self.provider.logger = MagicMock()
+
+        record = self.provider._query_record("zone123", "notfound", "example.com", "A", None, {})
+
+        self.assertIsNone(record)
+        self.provider.logger.warning.assert_called_once()
+
+    @patch("ddns.provider.dnspod.DnspodProvider._request")
+    def test_create_record_success(self, mock_request):
+        """Test _create_record method with successful creation"""
+        mock_request.return_value = {"record": {"id": "12345", "name": "www", "value": "192.168.1.1"}}
+
+        # Mock logger
+        self.provider.logger = MagicMock()
+
+        result = self.provider._create_record(
+            "zone123", "www", "example.com", "192.168.1.1", "A", ttl=600, line="电信", extra={}
+        )
+
+        self.assertTrue(result)
+        self.provider.logger.info.assert_called_once()
+        mock_request.assert_called_once_with(
+            "Record.Create",
+            extra={},
+            domain_id="zone123",
+            sub_domain="www",
+            value="192.168.1.1",
+            record_type="A",
+            record_line="电信",
+            ttl=600,
+        )
+
+    @patch("ddns.provider.dnspod.DnspodProvider._request")
+    def test_create_record_with_default_line(self, mock_request):
+        """Test _create_record method with default line"""
+        mock_request.return_value = {"record": {"id": "12345", "name": "www", "value": "192.168.1.1"}}
+
+        result = self.provider._create_record("zone123", "www", "example.com", "192.168.1.1", "A", None, None, {})
+
+        self.assertTrue(result)
+        # Should use DefaultLine when line is not specified
+        call_args = mock_request.call_args[1]
+        self.assertEqual(call_args["record_line"], "默认")
+
+    @patch("ddns.provider.dnspod.DnspodProvider._request")
+    def test_create_record_failure(self, mock_request):
+        """Test _create_record method with failed creation"""
+        mock_request.return_value = None
+
+        # Mock logger
+        self.provider.logger = MagicMock()
+
+        result = self.provider._create_record("zone123", "www", "example.com", "192.168.1.1", "A", None, None, {})
+
+        self.assertFalse(result)
+        self.provider.logger.error.assert_called_once()
+
+    @patch("ddns.provider.dnspod.DnspodProvider._request")
+    def test_create_record_with_extra_params(self, mock_request):
+        """Test _create_record method with extra parameters"""
+        mock_request.return_value = {"record": {"id": "12345", "name": "www", "value": "192.168.1.1"}}
+
+        extra = {"weight": 10}
+        result = self.provider._create_record("zone123", "www", "example.com", "192.168.1.1", "A", None, None, extra)
+
+        self.assertTrue(result)
+        mock_request.assert_called_once_with(
+            "Record.Create",
+            extra=extra,
+            domain_id="zone123",
+            sub_domain="www",
+            value="192.168.1.1",
+            record_type="A",
+            record_line="默认",
+            ttl=None,
+        )
+
+    @patch("ddns.provider.dnspod.DnspodProvider._request")
+    def test_update_record_success(self, mock_request):
+        """Test _update_record method with successful update"""
+        mock_request.return_value = {"record": {"id": "12345", "name": "www", "value": "192.168.1.2"}}
+
+        old_record = {"id": "12345", "name": "www", "line": "电信"}
+
+        # Mock logger
+        self.provider.logger = MagicMock()
+
+        result = self.provider._update_record("zone123", old_record, "192.168.1.2", "A", 300, None, {})
+
+        self.assertTrue(result)
+        self.provider.logger.debug.assert_called_once()
+        mock_request.assert_called_once_with(
+            "Record.Modify",
+            domain_id="zone123",
+            record_id="12345",
+            sub_domain="www",
+            record_type="A",
+            value="192.168.1.2",
+            record_line="电信",
+            extra={},
+        )
+
+    @patch("ddns.provider.dnspod.DnspodProvider._request")
+    def test_update_record_failure(self, mock_request):
+        """Test _update_record method with failed update"""
+        mock_request.return_value = None
+
+        old_record = {"id": "12345", "name": "www"}
+
+        # Mock logger
+        self.provider.logger = MagicMock()
+
+        result = self.provider._update_record("zone123", old_record, "192.168.1.2", "A", None, None, {})
+
+        self.assertFalse(result)
+        self.provider.logger.error.assert_called_once()
+
+    @patch("ddns.provider.dnspod.DnspodProvider._request")
+    def test_update_record_with_line_conversion(self, mock_request):
+        """Test _update_record method with line conversion (Default -> default)"""
+        mock_request.return_value = {"record": {"id": "12345", "name": "www", "value": "192.168.1.2"}}
+
+        old_record = {"id": "12345", "name": "www", "line": "Default"}
+
+        result = self.provider._update_record("zone123", old_record, "192.168.1.2", "A", None, None, {})
+
+        self.assertTrue(result)
+        # Should convert "Default" to "default"
+        call_args = mock_request.call_args[1]
+        self.assertEqual(call_args["record_line"], "default")
+
+    @patch("ddns.provider.dnspod.DnspodProvider._request")
+    def test_update_record_with_fallback_line(self, mock_request):
+        """Test _update_record method with fallback to default line"""
+        mock_request.return_value = {"record": {"id": "12345", "name": "www", "value": "192.168.1.2"}}
+
+        old_record = {"id": "12345", "name": "www"}  # No line specified
+
+        result = self.provider._update_record("zone123", old_record, "192.168.1.2", "A", None, None, {})
+
+        self.assertTrue(result)
+        # Should use DefaultLine when old record has no line
+        call_args = mock_request.call_args[1]
+        self.assertEqual(call_args["record_line"], "默认")
+
+    @patch("ddns.provider.dnspod.DnspodProvider._request")
+    def test_update_record_with_extra_params(self, mock_request):
+        """Test _update_record method with extra parameters"""
+        mock_request.return_value = {"record": {"id": "12345", "name": "www", "value": "192.168.1.2"}}
+
+        old_record = {"id": "12345", "name": "www", "line": "电信"}
+        extra = {"weight": 20}
+
+        result = self.provider._update_record("zone123", old_record, "192.168.1.2", "A", None, None, extra)
+
+        self.assertTrue(result)
+        call_args = mock_request.call_args[1]
+        self.assertEqual(call_args["extra"], extra)
+
+    def test_request_with_none_response(self):
+        """Test _request method when HTTP returns None"""
+        with patch("ddns.provider.dnspod.DnspodProvider._http") as mock_http:
+            mock_http.return_value = None
+            self.provider.logger = MagicMock()
+
+            # Should return None and log a warning
+            result = self.provider._request("Test.Action")
+
+            self.assertIsNone(result)
+            # Verify warning was logged
+            self.provider.logger.warning.assert_called_once()
+
+    def test_create_record_with_no_record_in_response(self):
+        """Test _create_record method when response has no record field"""
+        with patch("ddns.provider.dnspod.DnspodProvider._request") as mock_request:
+            mock_request.return_value = {"status": {"code": "1"}}  # No record field
+            self.provider.logger = MagicMock()
+
+            result = self.provider._create_record("zone123", "www", "example.com", "192.168.1.1", "A", None, None, {})
+
+            self.assertFalse(result)
+            self.provider.logger.error.assert_called_once()
+
+    def test_update_record_with_no_record_in_response(self):
+        """Test _update_record method when response has no record field"""
+        with patch("ddns.provider.dnspod.DnspodProvider._request") as mock_request:
+            mock_request.return_value = {"status": {"code": "1"}}  # No record field
+            old_record = {"id": "12345", "name": "www"}
+            self.provider.logger = MagicMock()
+
+            result = self.provider._update_record("zone123", old_record, "192.168.1.2", "A", None, None, {})
+
+            self.assertFalse(result)
+            self.provider.logger.error.assert_called_once()
+
+
+class TestDnspodProviderIntegration(BaseProviderTestCase):
+    """DNSPod Provider 集成测试类"""
+
+    def setUp(self):
+        """测试初始化"""
+        super(TestDnspodProviderIntegration, self).setUp()
+        self.provider = DnspodProvider(self.auth_id, self.auth_token)
+        self.provider.logger = MagicMock()
+
+    @patch("ddns.provider.dnspod.DnspodProvider._http")
+    def test_full_workflow_create_record(self, mock_http):
+        """Test complete workflow for creating a new record"""
+        # Mock HTTP responses for the workflow
+        responses = [
+            # Domain.Info response
+            {"status": {"code": "1"}, "domain": {"id": "zone123"}},
+            # Record.List response (no existing records)
+            {"status": {"code": "1"}, "records": []},
+            # Record.Create response
+            {"status": {"code": "1"}, "record": {"id": "rec123", "name": "www", "value": "192.168.1.1"}},
+        ]
+        mock_http.side_effect = responses
+
+        result = self.provider.set_record("www.example.com", "192.168.1.1")
+
+        self.assertTrue(result)
+        self.assertEqual(mock_http.call_count, 3)
+
+    @patch("ddns.provider.dnspod.DnspodProvider._http")
+    def test_full_workflow_update_record(self, mock_http):
+        """Test complete workflow for updating an existing record"""
+        # Mock HTTP responses for the workflow
+        responses = [
+            # Domain.Info response
+            {"status": {"code": "1"}, "domain": {"id": "zone123"}},
+            # Record.List response (existing record found)
+            {
+                "status": {"code": "1"},
+                "records": [{"id": "rec123", "name": "www", "value": "192.168.1.100", "line": "默认"}],
+            },
+            # Record.Modify response
+            {"status": {"code": "1"}, "record": {"id": "rec123", "name": "www", "value": "192.168.1.1"}},
+        ]
+        mock_http.side_effect = responses
+
+        result = self.provider.set_record("www.example.com", "192.168.1.1")
+
+        self.assertTrue(result)
+        self.assertEqual(mock_http.call_count, 3)
+
+    @patch("ddns.provider.dnspod.DnspodProvider._http")
+    def test_full_workflow_zone_not_found(self, mock_http):
+        """Test complete workflow when zone is not found"""
+        # Domain.Info response - no domain found
+        mock_http.return_value = {"status": {"code": "0", "message": "Domain not found"}}
+
+        # Should return False when zone not found
+        result = self.provider.set_record("www.notfound.com", "192.168.1.1")
+        self.assertFalse(result)
+
+    @patch("ddns.provider.dnspod.DnspodProvider._http")
+    def test_full_workflow_create_failure(self, mock_http):
+        """Test complete workflow when record creation fails"""
+        responses = [
+            # Domain.Info response
+            {"status": {"code": "1"}, "domain": {"id": "zone123"}},
+            # Record.List response (no existing records)
+            {"status": {"code": "1"}, "records": []},
+            # Record.Create response (failure)
+            {"status": {"code": "0", "message": "Create failed"}},
+        ]
+        mock_http.side_effect = responses
+
+        result = self.provider.set_record("www.example.com", "192.168.1.1")
+
+        self.assertFalse(result)
+        self.assertEqual(mock_http.call_count, 3)
+
+    @patch("ddns.provider.dnspod.DnspodProvider._http")
+    def test_full_workflow_update_failure(self, mock_http):
+        """Test complete workflow when record update fails"""
+        responses = [
+            # Domain.Info response
+            {"status": {"code": "1"}, "domain": {"id": "zone123"}},
+            # Record.List response (existing record found)
+            {
+                "status": {"code": "1"},
+                "records": [{"id": "rec123", "name": "www", "value": "192.168.1.100", "line": "默认"}],
+            },
+            # Record.Modify response (failure)
+            {"status": {"code": "0", "message": "Update failed"}},
+        ]
+        mock_http.side_effect = responses
+
+        result = self.provider.set_record("www.example.com", "192.168.1.1")
+
+        self.assertFalse(result)
+        self.assertEqual(mock_http.call_count, 3)
+
+    @patch("ddns.provider.dnspod.DnspodProvider._http")
+    def test_full_workflow_with_options(self, mock_http):
+        """Test complete workflow with additional options like ttl and line"""
+        responses = [
+            # Domain.Info response
+            {"status": {"code": "1"}, "domain": {"id": "zone123"}},
+            # Record.List response (no existing records)
+            {"status": {"code": "1"}, "records": []},
+            # Record.Create response
+            {"status": {"code": "1"}, "record": {"id": "rec123", "name": "www", "value": "192.168.1.1"}},
+        ]
+        mock_http.side_effect = responses
+
+        result = self.provider.set_record("www.example.com", "192.168.1.1", record_type="A", ttl=300, line="电信")
+
+        self.assertTrue(result)
+        self.assertEqual(mock_http.call_count, 3)
+
+        # Verify the Record.Create call includes the custom options
+        create_call = mock_http.call_args_list[2]
+        body = create_call[1]["body"]
+        self.assertEqual(body["ttl"], 300)
+        self.assertEqual(body["record_line"], "电信")
+
+
+class TestDnspodProviderRealRequest(BaseProviderTestCase):
+    """DNSPod Provider 真实请求测试类"""
+
+    def test_auth_failure_real_request(self):
+        """Test authentication failure with real API request"""
+        # 使用无效的认证信息创建 provider
+        invalid_provider = DnspodProvider("invalid_id", "invalid_token")
+
+        # 尝试查询域名信息,应该抛出认证失败异常
+        with self.assertRaises(RuntimeError) as cm:
+            invalid_provider._query_zone_id("example.com")
+
+        # 验证异常信息包含认证失败 - 更新错误消息格式
+        error_message = str(cm.exception)
+        self.assertTrue(
+            "认证失败" in error_message and "401" in error_message,
+            "Expected authentication error message not found in: {}".format(error_message),
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 74 - 0
tests/test_provider_dnspod_com.py

@@ -0,0 +1,74 @@
+# coding=utf-8
+"""
+Unit tests for DnspodComProvider
+
+@author: GitHub Copilot
+"""
+
+from base_test import BaseProviderTestCase, unittest
+from ddns.provider.dnspod_com import DnspodComProvider
+
+
+class TestDnspodComProvider(BaseProviderTestCase):
+    """Test cases for DnspodComProvider"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        super(TestDnspodComProvider, self).setUp()
+        self.auth_id = "[email protected]"
+        self.auth_token = "test_token"
+
+    def test_class_constants(self):
+        """Test DnspodComProvider class constants"""
+        self.assertEqual(DnspodComProvider.API, "https://api.dnspod.com")
+        self.assertEqual(DnspodComProvider.DefaultLine, "default")
+
+    def test_init_with_basic_config(self):
+        """Test DnspodComProvider initialization with basic configuration"""
+        provider = DnspodComProvider(self.auth_id, self.auth_token)
+        self.assertEqual(provider.auth_id, self.auth_id)
+        self.assertEqual(provider.auth_token, self.auth_token)
+        self.assertEqual(provider.API, "https://api.dnspod.com")
+
+    def test_inheritance_from_dnspod(self):
+        """Test that DnspodComProvider properly inherits from DnspodProvider"""
+        from ddns.provider.dnspod import DnspodProvider
+
+        provider = DnspodComProvider(self.auth_id, self.auth_token)
+        self.assertIsInstance(provider, DnspodProvider)
+        # Should have inherited methods from parent
+        self.assertTrue(hasattr(provider, "_request"))
+        self.assertTrue(hasattr(provider, "_query_zone_id"))
+        self.assertTrue(hasattr(provider, "_query_record"))
+        self.assertTrue(hasattr(provider, "_create_record"))
+        self.assertTrue(hasattr(provider, "_update_record"))
+
+
+class TestDnspodComProviderIntegration(BaseProviderTestCase):
+    """Integration test cases for DnspodComProvider"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        super(TestDnspodComProviderIntegration, self).setUp()
+        self.auth_id = "[email protected]"
+        self.auth_token = "test_token"
+
+    def test_api_endpoint_difference(self):
+        """Test that DnspodComProvider uses different API endpoint than DnspodProvider"""
+        from ddns.provider.dnspod import DnspodProvider
+
+        dnspod_provider = DnspodProvider(self.auth_id, self.auth_token)
+        dnspod_com_provider = DnspodComProvider(self.auth_id, self.auth_token)
+
+        # Should use different API endpoints
+        self.assertNotEqual(dnspod_provider.API, dnspod_com_provider.API)
+        self.assertEqual(dnspod_com_provider.API, "https://api.dnspod.com")
+
+    def test_default_line_setting(self):
+        """Test that DnspodComProvider uses correct default line"""
+        provider = DnspodComProvider(self.auth_id, self.auth_token)
+        self.assertEqual(provider.DefaultLine, "default")
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 370 - 0
tests/test_provider_he.py

@@ -0,0 +1,370 @@
+# coding=utf-8
+"""
+Unit tests for HeProvider (Hurricane Electric)
+
+@author: GitHub Copilot
+"""
+
+from base_test import BaseProviderTestCase, unittest, patch, MagicMock
+from ddns.provider.he import HeProvider
+
+
+class TestHeProvider(BaseProviderTestCase):
+    """Test cases for HeProvider"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        super(TestHeProvider, self).setUp()
+        # Override default auth values for HE provider - HE uses empty auth_id
+        self.auth_id = ""
+        self.auth_token = "test_password"
+
+    def test_init_with_basic_config(self):
+        """Test HeProvider initialization with basic configuration"""
+        # HE provider should use empty auth_id and only auth_token
+        provider = HeProvider("", self.auth_token)
+        self.assertEqual(provider.auth_id, "")
+        self.assertEqual(provider.auth_token, self.auth_token)
+        self.assertEqual(provider.API, "https://dyn.dns.he.net")
+        self.assertFalse(provider.decode_response)
+
+    def test_class_constants(self):
+        """Test HeProvider class constants"""
+        provider = HeProvider("", self.auth_token)
+        self.assertEqual(provider.API, "https://dyn.dns.he.net")
+        self.assertFalse(provider.decode_response)
+        # ContentType should be form-encoded
+        from ddns.provider._base import TYPE_FORM
+
+        self.assertEqual(provider.content_type, TYPE_FORM)
+
+    def test_validate_success_with_token_only(self):
+        """Test _validate method passes with token only (correct usage)"""
+        provider = HeProvider("", self.auth_token)
+        # Should not raise any exception
+        provider._validate()
+
+    def test_validate_fails_with_auth_id(self):
+        """Test _validate method fails when auth_id is provided"""
+        with self.assertRaises(ValueError) as cm:
+            HeProvider("some_id", self.auth_token)
+        self.assertIn("does not use `id`", str(cm.exception))
+
+    def test_validate_fails_without_token(self):
+        """Test _validate method fails when auth_token is missing"""
+        with self.assertRaises(ValueError) as cm:
+            HeProvider("", "")
+        self.assertIn("requires `token(password)`", str(cm.exception))
+
+    @patch.object(HeProvider, "_http")
+    def test_set_record_success_good_response(self, mock_http):
+        """Test set_record method with 'good' response"""
+        mock_http.return_value = "good 192.168.1.1"
+
+        provider = HeProvider("", self.auth_token)
+
+        result = provider.set_record("example.com", "192.168.1.1", "A")
+
+        # Verify the result
+        self.assertTrue(result)
+
+        # Verify _http was called with correct parameters
+        mock_http.assert_called_once()
+        args, kwargs = mock_http.call_args
+        self.assertEqual(args[0], "POST")  # method
+        self.assertEqual(args[1], "/nic/update")  # path
+
+        # Check body parameters
+        body = kwargs["body"]
+        self.assertEqual(body["hostname"], "example.com")
+        self.assertEqual(body["myip"], "192.168.1.1")
+        self.assertEqual(body["password"], self.auth_token)
+
+    @patch.object(HeProvider, "_http")
+    def test_set_record_success_nochg_response(self, mock_http):
+        """Test set_record method with 'nochg' response"""
+        mock_http.return_value = "nochg 192.168.1.1"
+
+        provider = HeProvider("", self.auth_token)
+
+        result = provider.set_record("test.example.com", "192.168.1.1", "A")
+
+        # Verify the result
+        self.assertTrue(result)
+
+        # Verify _http was called with correct parameters
+        mock_http.assert_called_once()
+        args, kwargs = mock_http.call_args
+        self.assertEqual(args[0], "POST")
+        self.assertEqual(args[1], "/nic/update")
+
+        # Check body parameters
+        body = kwargs["body"]
+        self.assertEqual(body["hostname"], "test.example.com")
+        self.assertEqual(body["myip"], "192.168.1.1")
+        self.assertEqual(body["password"], self.auth_token)
+
+    @patch.object(HeProvider, "_http")
+    def test_set_record_ipv6_address(self, mock_http):
+        """Test set_record method with IPv6 address"""
+        mock_http.return_value = "good 2001:db8::1"
+
+        provider = HeProvider("", self.auth_token)
+
+        result = provider.set_record("ipv6.example.com", "2001:db8::1", "AAAA")
+
+        # Verify the result
+        self.assertTrue(result)
+
+        # Check body parameters
+        args, kwargs = mock_http.call_args
+        body = kwargs["body"]
+        self.assertEqual(body["hostname"], "ipv6.example.com")
+        self.assertEqual(body["myip"], "2001:db8::1")
+
+    @patch.object(HeProvider, "_http")
+    def test_set_record_with_all_parameters(self, mock_http):
+        """Test set_record method with all optional parameters"""
+        mock_http.return_value = "good 10.0.0.1"
+
+        provider = HeProvider("", self.auth_token)
+
+        result = provider.set_record(
+            domain="full.example.com", value="10.0.0.1", record_type="A", ttl=300, line="default", extra_param="test"
+        )
+
+        # Verify the result
+        self.assertTrue(result)
+
+        # Check that core parameters are still correct
+        args, kwargs = mock_http.call_args
+        body = kwargs["body"]
+        self.assertEqual(body["hostname"], "full.example.com")
+        self.assertEqual(body["myip"], "10.0.0.1")
+        self.assertEqual(body["password"], self.auth_token)
+
+    @patch.object(HeProvider, "_http")
+    def test_set_record_empty_response_error(self, mock_http):
+        """Test set_record method with empty response (should return False)"""
+        mock_http.return_value = ""  # Empty response
+
+        provider = HeProvider("", self.auth_token)
+        provider.logger = MagicMock()
+
+        result = provider.set_record("example.com", "192.168.1.1")
+        self.assertFalse(result)
+
+        # Verify error was logged
+        provider.logger.error.assert_called_once_with("HE API error: %s", "")
+
+    @patch.object(HeProvider, "_http")
+    def test_set_record_none_response_error(self, mock_http):
+        """Test set_record method with None response (should return False)"""
+        mock_http.return_value = None  # None response
+
+        provider = HeProvider("", self.auth_token)
+        provider.logger = MagicMock()
+
+        result = provider.set_record("example.com", "192.168.1.1")
+        self.assertFalse(result)
+
+        # Verify error was logged - None causes TypeError when slicing
+        provider.logger.error.assert_called_once()
+        args = provider.logger.error.call_args[0]
+        self.assertEqual(args[0], "Error updating record for %s: %s")
+        self.assertEqual(args[1], "example.com")
+        self.assertIsInstance(args[2], TypeError)
+
+    @patch.object(HeProvider, "_http")
+    def test_set_record_http_exception(self, mock_http):
+        """Test set_record method when _http raises an exception"""
+        mock_http.side_effect = Exception("Network error")
+
+        provider = HeProvider("", self.auth_token)
+        provider.logger = MagicMock()
+
+        result = provider.set_record("example.com", "192.168.1.1")
+        self.assertFalse(result)
+
+        # Verify error was logged
+        provider.logger.error.assert_called_once()
+        args = provider.logger.error.call_args[0]
+        self.assertEqual(args[0], "Error updating record for %s: %s")
+        self.assertEqual(args[1], "example.com")
+        self.assertIsInstance(args[2], Exception)
+
+    @patch.object(HeProvider, "_http")
+    def test_set_record_error_response(self, mock_http):
+        """Test set_record method with error response"""
+        mock_http.return_value = "badauth"
+
+        provider = HeProvider("", self.auth_token)
+        provider.logger = MagicMock()
+
+        result = provider.set_record("example.com", "192.168.1.1")
+        self.assertFalse(result)
+
+        # Verify error was logged
+        provider.logger.error.assert_called_once_with("HE API error: %s", "badauth")
+
+    @patch.object(HeProvider, "_http")
+    def test_set_record_abuse_response(self, mock_http):
+        """Test set_record method with abuse response"""
+        mock_http.return_value = "abuse"
+
+        provider = HeProvider("", self.auth_token)
+        provider.logger = MagicMock()
+
+        result = provider.set_record("example.com", "192.168.1.1")
+        self.assertFalse(result)
+
+        # Verify error was logged
+        provider.logger.error.assert_called_once_with("HE API error: %s", "abuse")
+
+    @patch.object(HeProvider, "_http")
+    def test_set_record_notfqdn_response(self, mock_http):
+        """Test set_record method with notfqdn response"""
+        mock_http.return_value = "notfqdn"
+
+        provider = HeProvider("", self.auth_token)
+        provider.logger = MagicMock()
+
+        result = provider.set_record("example.com", "192.168.1.1")
+        self.assertFalse(result)
+
+        # Verify error was logged
+        provider.logger.error.assert_called_once_with("HE API error: %s", "notfqdn")
+
+    @patch.object(HeProvider, "_http")
+    def test_set_record_partial_good_response(self, mock_http):
+        """Test set_record method with partial 'good' response"""
+        mock_http.return_value = "good"  # Just 'good' without IP
+
+        provider = HeProvider("", self.auth_token)
+        provider.logger = MagicMock()
+
+        result = provider.set_record("example.com", "192.168.1.1")
+
+        # Should return True as success
+        self.assertTrue(result)
+
+        # Verify success was logged
+        provider.logger.info.assert_any_call("HE API response: %s", "good")
+
+    @patch.object(HeProvider, "_http")
+    def test_set_record_partial_nochg_response(self, mock_http):
+        """Test set_record method with partial 'nochg' response"""
+        mock_http.return_value = "nochg"  # Just 'nochg' without IP
+
+        provider = HeProvider("", self.auth_token)
+        provider.logger = MagicMock()
+
+        result = provider.set_record("example.com", "192.168.1.1")
+
+        # Should return True as success
+        self.assertTrue(result)
+
+        # Verify success was logged
+        provider.logger.info.assert_any_call("HE API response: %s", "nochg")
+
+    def test_set_record_logger_info_called(self):
+        """Test that logger.info is called with correct parameters"""
+        provider = HeProvider("", self.auth_token)
+
+        # Mock the logger and _http
+        provider.logger = MagicMock()
+
+        with patch.object(provider, "_http") as mock_http:
+            mock_http.return_value = "good 192.168.1.1"
+
+            provider.set_record("example.com", "192.168.1.1", "A")
+
+        # Verify logger.info was called with correct parameters for the initial log
+        provider.logger.info.assert_any_call("%s => %s(%s)", "example.com", "192.168.1.1", "A")
+
+    def test_set_record_logger_info_on_success(self):
+        """Test that logger.info is called on success"""
+        provider = HeProvider("", self.auth_token)
+
+        # Mock the logger and _http
+        provider.logger = MagicMock()
+
+        with patch.object(provider, "_http") as mock_http:
+            mock_http.return_value = "good 192.168.1.1"
+
+            provider.set_record("example.com", "192.168.1.1", "A")
+
+        # Verify logger.info was called for successful response
+        calls = provider.logger.info.call_args_list
+        self.assertEqual(len(calls), 2)  # Initial log and success log
+
+    def test_set_record_logger_error_called(self):
+        """Test that logger.error is called on error response"""
+        provider = HeProvider("", self.auth_token)
+
+        # Mock the logger and _http
+        provider.logger = MagicMock()
+
+        with patch.object(provider, "_http") as mock_http:
+            mock_http.return_value = "badauth"
+
+            result = provider.set_record("example.com", "192.168.1.1", "A")
+            self.assertFalse(result)
+
+        # Verify logger.error was called with correct parameters
+        provider.logger.error.assert_called_once_with("HE API error: %s", "badauth")
+
+
+class TestHeProviderIntegration(BaseProviderTestCase):
+    """Integration tests for HeProvider"""
+
+    def test_full_workflow_ipv4_success(self):
+        """Test complete workflow for IPv4 record with success response"""
+        provider = HeProvider("", "test_auth_token")
+
+        with patch.object(provider, "_http") as mock_http:
+            mock_http.return_value = "good 1.2.3.4"
+
+            result = provider.set_record("test.com", "1.2.3.4", "A", 300, "default")
+
+            self.assertTrue(result)
+            mock_http.assert_called_once()
+            args, kwargs = mock_http.call_args
+            self.assertEqual(args[0], "POST")
+            self.assertEqual(args[1], "/nic/update")
+
+            body = kwargs["body"]
+            self.assertEqual(body["hostname"], "test.com")
+            self.assertEqual(body["myip"], "1.2.3.4")
+            self.assertEqual(body["password"], "test_auth_token")
+
+    def test_full_workflow_ipv6_success(self):
+        """Test complete workflow for IPv6 record with success response"""
+        provider = HeProvider("", "test_auth_token")
+
+        with patch.object(provider, "_http") as mock_http:
+            mock_http.return_value = "good ::1"
+
+            result = provider.set_record("test.com", "::1", "AAAA", 600, "telecom")
+
+            self.assertTrue(result)
+            mock_http.assert_called_once()
+
+            args, kwargs = mock_http.call_args
+            body = kwargs["body"]
+            self.assertEqual(body["hostname"], "test.com")
+            self.assertEqual(body["myip"], "::1")
+
+    def test_full_workflow_error_handling(self):
+        """Test complete workflow with error handling"""
+        provider = HeProvider("", "test_auth_token")
+
+        with patch.object(provider, "_http") as mock_http:
+            mock_http.return_value = "badauth"
+
+            result = provider.set_record("test.com", "1.2.3.4", "A")
+            self.assertFalse(result)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 482 - 0
tests/test_provider_huaweidns.py

@@ -0,0 +1,482 @@
+# coding=utf-8
+"""
+Unit tests for HuaweiDNSProvider
+
+@author: GitHub Copilot
+"""
+
+from base_test import BaseProviderTestCase, unittest, patch
+from ddns.provider.huaweidns import HuaweiDNSProvider
+
+
+class TestHuaweiDNSProvider(BaseProviderTestCase):
+    """Test cases for HuaweiDNSProvider"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        super(TestHuaweiDNSProvider, self).setUp()
+        self.auth_id = "test_access_key"
+        self.auth_token = "test_secret_key"
+        self.provider = HuaweiDNSProvider(self.auth_id, self.auth_token)
+
+        # Mock strftime for all tests
+        self.strftime_patcher = patch("ddns.provider.huaweidns.strftime")
+        self.mock_strftime = self.strftime_patcher.start()
+        self.mock_strftime.return_value = "20230101T120000Z"
+
+    def tearDown(self):
+        """Clean up test fixtures"""
+        self.strftime_patcher.stop()
+        super(TestHuaweiDNSProvider, self).tearDown()
+
+    def test_class_constants(self):
+        """Test HuaweiDNSProvider class constants"""
+        self.assertEqual(HuaweiDNSProvider.API, "https://dns.myhuaweicloud.com")
+        self.assertEqual(HuaweiDNSProvider.content_type, "application/json")
+        self.assertTrue(HuaweiDNSProvider.decode_response)
+        self.assertEqual(HuaweiDNSProvider.algorithm, "SDK-HMAC-SHA256")
+
+    def test_init_with_basic_config(self):
+        """Test HuaweiDNSProvider initialization with basic configuration"""
+        self.assertEqual(self.provider.auth_id, self.auth_id)
+        self.assertEqual(self.provider.auth_token, self.auth_token)
+        self.assertEqual(self.provider.API, "https://dns.myhuaweicloud.com")
+
+    def test_hex_encode_sha256(self):
+        """Test _hex_encode_sha256 method"""
+        test_data = b"test data"
+        result = self.provider._hex_encode_sha256(test_data)
+
+        # Should return a 64-character hex string (SHA256)
+        self.assertEqual(len(result), 64)
+        self.assertIsInstance(result, str)
+        # SHA256 of "test data"
+        expected_hash = "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9"
+        self.assertEqual(result, expected_hash)
+
+    def test_sign_headers(self):
+        """Test _sign_headers method"""
+        headers = {
+            "Content-Type": "application/json",
+            "Host": "dns.myhuaweicloud.com",
+            "X-Sdk-Date": "20230101T000000Z",
+        }
+        signed_headers = ["content-type", "host", "x-sdk-date"]
+
+        result = self.provider._sign_headers(headers, signed_headers)
+
+        expected = "content-type:application/json\nhost:dns.myhuaweicloud.com\nx-sdk-date:20230101T000000Z\n"
+        self.assertEqual(result, expected)
+
+    def test_request_get_method(self):
+        """Test _request method with GET method"""
+        with patch.object(self.provider, "_http") as mock_http:
+            mock_http.return_value = {"zones": []}
+
+            result = self.provider._request("GET", "/v2/zones", name="example.com", limit=500)
+
+            mock_http.assert_called_once()
+            self.assertEqual(result, {"zones": []})
+
+    def test_request_post_method(self):
+        """Test _request method with POST method"""
+        with patch.object(self.provider, "_http") as mock_http:
+            mock_http.return_value = {"id": "record123"}
+
+            result = self.provider._request(
+                "POST", "/v2.1/zones/zone123/recordsets", name="www.example.com", type="A", records=["1.2.3.4"]
+            )
+
+            mock_http.assert_called_once()
+            self.assertEqual(result, {"id": "record123"})
+
+    def test_request_filters_none_params(self):
+        """Test _request method filters out None parameters"""
+        with patch.object(self.provider, "_http") as mock_http:
+            mock_http.return_value = {"zones": []}
+
+            self.provider._request("GET", "/v2/zones", name="example.com", limit=None, type=None)
+
+            # Verify that _http was called (None params should be filtered)
+            mock_http.assert_called_once()
+
+    def test_query_zone_id_success(self):
+        """Test _query_zone_id method with successful response"""
+        with patch.object(self.provider, "_request") as mock_request:
+            mock_request.return_value = {
+                "zones": [{"id": "zone123", "name": "example.com."}, {"id": "zone456", "name": "another.com."}]
+            }
+
+            result = self.provider._query_zone_id("example.com")
+
+            mock_request.assert_called_once_with(
+                "GET", "/v2/zones", search_mode="equal", limit=500, name="example.com."
+            )
+            self.assertEqual(result, "zone123")
+
+    def test_query_zone_id_with_trailing_dot(self):
+        """Test _query_zone_id method with domain already having trailing dot"""
+        with patch.object(self.provider, "_request") as mock_request:
+            mock_request.return_value = {"zones": [{"id": "zone123", "name": "example.com."}]}
+
+            result = self.provider._query_zone_id("example.com.")
+
+            mock_request.assert_called_once_with(
+                "GET", "/v2/zones", search_mode="equal", limit=500, name="example.com."
+            )
+            self.assertEqual(result, "zone123")
+
+    def test_query_zone_id_not_found(self):
+        """Test _query_zone_id method when domain is not found"""
+        with patch.object(self.provider, "_request") as mock_request:
+            mock_request.return_value = {"zones": []}
+
+            result = self.provider._query_zone_id("notfound.com")
+
+            self.assertIsNone(result)
+
+    def test_query_record_success(self):
+        """Test _query_record method with successful response"""
+        with patch.object(self.provider, "_request") as mock_request:
+            mock_request.return_value = {
+                "recordsets": [
+                    {"id": "rec123", "name": "www.example.com.", "type": "A", "records": ["1.2.3.4"]},
+                    {"id": "rec456", "name": "mail.example.com.", "type": "A", "records": ["5.6.7.8"]},
+                ]
+            }
+
+            result = self.provider._query_record("zone123", "www", "example.com", "A", None, {})
+
+            mock_request.assert_called_once_with(
+                "GET",
+                "/v2.1/zones/zone123/recordsets",
+                limit=500,
+                name="www.example.com.",
+                type="A",
+                line_id=None,
+                search_mode="equal",
+            )
+            self.assertIsNotNone(result)
+            if result:  # Type narrowing
+                self.assertEqual(result["id"], "rec123")
+                self.assertEqual(result["name"], "www.example.com.")
+
+    def test_query_record_with_line(self):
+        """Test _query_record method with line parameter"""
+        with patch.object(self.provider, "_request") as mock_request:
+            mock_request.return_value = {"recordsets": []}
+
+            self.provider._query_record("zone123", "www", "example.com", "A", "line1", {})
+
+            mock_request.assert_called_once_with(
+                "GET",
+                "/v2.1/zones/zone123/recordsets",
+                limit=500,
+                name="www.example.com.",
+                type="A",
+                line_id="line1",
+                search_mode="equal",
+            )
+
+    def test_query_record_not_found(self):
+        """Test _query_record method when no matching record is found"""
+        with patch.object(self.provider, "_request") as mock_request:
+            mock_request.return_value = {
+                "recordsets": [{"id": "rec456", "name": "mail.example.com.", "type": "A", "records": ["5.6.7.8"]}]
+            }
+
+            result = self.provider._query_record("zone123", "www", "example.com", "A", None, {})
+
+            self.assertIsNone(result)
+
+    def test_create_record_success(self):
+        """Test _create_record method with successful creation"""
+        with patch.object(self.provider, "_request") as mock_request:
+            mock_request.return_value = {"id": "rec123456"}
+
+            result = self.provider._create_record("zone123", "www", "example.com", "1.2.3.4", "A", 300, "line1", {})
+
+            mock_request.assert_called_once_with(
+                "POST",
+                "/v2.1/zones/zone123/recordsets",
+                name="www.example.com.",
+                type="A",
+                records=["1.2.3.4"],
+                ttl=300,
+                line="line1",
+                description=self.provider.remark,
+            )
+            self.assertTrue(result)
+
+    def test_create_record_with_extra_params(self):
+        """Test _create_record method with extra parameters"""
+        with patch.object(self.provider, "_request") as mock_request:
+            mock_request.return_value = {"id": "rec123456"}
+
+            extra = {"description": "Custom description", "tags": ["tag1", "tag2"]}
+            result = self.provider._create_record("zone123", "www", "example.com", "1.2.3.4", "A", 300, None, extra)
+
+            mock_request.assert_called_once_with(
+                "POST",
+                "/v2.1/zones/zone123/recordsets",
+                name="www.example.com.",
+                type="A",
+                records=["1.2.3.4"],
+                ttl=300,
+                line=None,
+                description="Custom description",
+                tags=["tag1", "tag2"],
+            )
+            self.assertTrue(result)
+
+    def test_create_record_failure(self):
+        """Test _create_record method with failed creation"""
+        provider = HuaweiDNSProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"error": "Zone not found"}
+
+            result = provider._create_record("zone123", "www", "example.com", "1.2.3.4", "A", None, None, {})
+
+            self.assertFalse(result)
+
+    def test_update_record_success(self):
+        """Test _update_record method with successful update"""
+        provider = HuaweiDNSProvider(self.auth_id, self.auth_token)
+
+        old_record = {"id": "rec123", "name": "www.example.com.", "ttl": 300}
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"id": "rec123"}
+
+            result = provider._update_record("zone123", old_record, "5.6.7.8", "A", 600, None, {})
+
+            mock_request.assert_called_once_with(
+                "PUT",
+                "/v2.1/zones/zone123/recordsets/rec123",
+                name="www.example.com.",
+                type="A",
+                records=["5.6.7.8"],
+                ttl=600,
+                description=provider.remark,
+            )
+            self.assertTrue(result)
+
+    def test_update_record_with_fallback_ttl(self):
+        """Test _update_record method uses old record's TTL when ttl is None"""
+        provider = HuaweiDNSProvider(self.auth_id, self.auth_token)
+
+        old_record = {"id": "rec123", "name": "www.example.com.", "ttl": 300}
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"id": "rec123"}
+
+            result = provider._update_record("zone123", old_record, "5.6.7.8", "A", None, None, {})
+
+            mock_request.assert_called_once_with(
+                "PUT",
+                "/v2.1/zones/zone123/recordsets/rec123",
+                name="www.example.com.",
+                type="A",
+                records=["5.6.7.8"],
+                ttl=300,
+                description=provider.remark,
+            )
+            self.assertTrue(result)
+
+    def test_update_record_with_extra_params(self):
+        """Test _update_record method with extra parameters"""
+        provider = HuaweiDNSProvider(self.auth_id, self.auth_token)
+
+        old_record = {"id": "rec123", "name": "www.example.com.", "ttl": 300}
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"id": "rec123"}
+
+            extra = {"description": "Updated description", "tags": ["newtag"]}
+            result = provider._update_record("zone123", old_record, "5.6.7.8", "A", 600, "line2", extra)
+
+            mock_request.assert_called_once_with(
+                "PUT",
+                "/v2.1/zones/zone123/recordsets/rec123",
+                name="www.example.com.",
+                type="A",
+                records=["5.6.7.8"],
+                ttl=600,
+                description="Updated description",
+                tags=["newtag"],
+            )
+            self.assertTrue(result)
+
+    def test_update_record_failure(self):
+        """Test _update_record method with failed update"""
+        provider = HuaweiDNSProvider(self.auth_id, self.auth_token)
+
+        old_record = {"id": "rec123", "name": "www.example.com."}
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"error": "Record not found"}
+
+            result = provider._update_record("zone123", old_record, "5.6.7.8", "A", None, None, {})
+
+            self.assertFalse(result)
+
+
+class TestHuaweiDNSProviderIntegration(BaseProviderTestCase):
+    """Integration test cases for HuaweiDNSProvider - testing with minimal mocking"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        super(TestHuaweiDNSProviderIntegration, self).setUp()
+        self.auth_id = "test_access_key"
+        self.auth_token = "test_secret_key"
+
+    def test_full_workflow_create_new_record(self):
+        """Test complete workflow for creating a new record"""
+        provider = HuaweiDNSProvider(self.auth_id, self.auth_token)
+
+        # Mock only the HTTP layer to simulate API responses
+        with patch.object(provider, "_request") as mock_request:
+            # Simulate API responses in order: zone query, record query, record creation
+            mock_request.side_effect = [
+                {"zones": [{"id": "zone123", "name": "example.com."}]},  # _query_zone_id response
+                {"recordsets": []},  # _query_record response (no existing record)
+                {"id": "rec123456"},  # _create_record response
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A", 300, "line1")
+
+            self.assertTrue(result)
+            # Verify the actual API calls made
+            self.assertEqual(mock_request.call_count, 3)
+            mock_request.assert_any_call("GET", "/v2/zones", search_mode="equal", limit=500, name="example.com.")
+            mock_request.assert_any_call(
+                "GET",
+                "/v2.1/zones/zone123/recordsets",
+                limit=500,
+                name="www.example.com.",
+                type="A",
+                line_id="line1",
+                search_mode="equal",
+            )
+            mock_request.assert_any_call(
+                "POST",
+                "/v2.1/zones/zone123/recordsets",
+                name="www.example.com.",
+                type="A",
+                records=["1.2.3.4"],
+                ttl=300,
+                line="line1",
+                description="Managed by [DDNS v0.0.0](https://ddns.newfuture.cc)",
+            )
+
+    def test_full_workflow_update_existing_record(self):
+        """Test complete workflow for updating an existing record"""
+        provider = HuaweiDNSProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            # Simulate API responses
+            mock_request.side_effect = [
+                {"zones": [{"id": "zone123", "name": "example.com."}]},  # _query_zone_id response
+                {  # _query_record response (existing record found)
+                    "recordsets": [
+                        {"id": "rec123", "name": "www.example.com.", "type": "A", "records": ["5.6.7.8"], "ttl": 300}
+                    ]
+                },
+                {"id": "rec123"},  # _update_record response
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A", 300, "line1")
+
+            self.assertTrue(result)
+            # Verify the update call was made
+            mock_request.assert_any_call(
+                "PUT",
+                "/v2.1/zones/zone123/recordsets/rec123",
+                name="www.example.com.",
+                type="A",
+                records=["1.2.3.4"],
+                ttl=300,
+                description="Managed by [DDNS v0.0.0](https://ddns.newfuture.cc)",
+            )
+
+    def test_full_workflow_zone_not_found(self):
+        """Test complete workflow when zone is not found"""
+        provider = HuaweiDNSProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            # Simulate API returning empty zones array
+            mock_request.return_value = {"zones": []}
+
+            result = provider.set_record("www.nonexistent.com", "1.2.3.4", "A")
+            self.assertFalse(result)
+
+    def test_full_workflow_create_failure(self):
+        """Test complete workflow when record creation fails"""
+        provider = HuaweiDNSProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            # Simulate responses: zone found, no existing record, creation fails
+            mock_request.side_effect = [
+                {"zones": [{"id": "zone123", "name": "example.com."}]},  # _query_zone_id response
+                {"recordsets": []},  # _query_record response (no existing record)
+                {"error": "Zone not found"},  # _create_record fails
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A")
+
+            self.assertFalse(result)
+
+    def test_full_workflow_update_failure(self):
+        """Test complete workflow when record update fails"""
+        provider = HuaweiDNSProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            # Simulate responses: zone found, existing record found, update fails
+            mock_request.side_effect = [
+                {"zones": [{"id": "zone123", "name": "example.com."}]},  # _query_zone_id response
+                {  # _query_record response (existing record found)
+                    "recordsets": [
+                        {"id": "rec123", "name": "www.example.com.", "type": "A", "records": ["5.6.7.8"], "ttl": 300}
+                    ]
+                },
+                {"error": "Update failed"},  # _update_record fails
+            ]
+
+            result = provider.set_record("www.example.com", "1.2.3.4", "A")
+
+            self.assertFalse(result)
+
+    def test_full_workflow_with_extra_options(self):
+        """Test complete workflow with additional options"""
+        provider = HuaweiDNSProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            # Simulate successful creation with custom options
+            mock_request.side_effect = [
+                {"zones": [{"id": "zone123", "name": "example.com."}]},  # _query_zone_id response
+                {"recordsets": []},  # _query_record response (no existing record)
+                {"id": "rec123456"},  # _create_record response
+            ]
+
+            result = provider.set_record(
+                "www.example.com", "1.2.3.4", "A", 600, "line2", description="Custom record", tags=["production"]
+            )
+
+            self.assertTrue(result)
+            # Verify that extra parameters are passed through correctly
+            mock_request.assert_any_call(
+                "POST",
+                "/v2.1/zones/zone123/recordsets",
+                name="www.example.com.",
+                type="A",
+                records=["1.2.3.4"],
+                ttl=600,
+                line="line2",
+                description="Custom record",
+                tags=["production"],
+            )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 310 - 0
tests/test_provider_simple.py

@@ -0,0 +1,310 @@
+# coding=utf-8
+"""
+Unit tests for SimpleProvider
+
+@author: GitHub Copilot
+"""
+
+from base_test import BaseProviderTestCase, unittest, MagicMock
+from ddns.provider._base import SimpleProvider, TYPE_FORM
+
+
+class _TestableSimpleProvider(SimpleProvider):
+    """Test implementation of SimpleProvider for testing purposes"""
+
+    API = "https://api.example.com"
+
+    def set_record(self, domain, value, record_type="A", ttl=None, line=None, **extra):
+        """Test implementation of set_record"""
+        self.logger.debug("_TestableSimpleProvider: %s(%s) => %s", domain, record_type, value)
+        return True
+
+
+class _TestableSimpleProviderClass(BaseProviderTestCase):
+    """Test cases for SimpleProvider base class"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        super(_TestableSimpleProviderClass, self).setUp()
+
+    def test_init_with_basic_config(self):
+        """Test SimpleProvider initialization with basic configuration"""
+        provider = _TestableSimpleProvider(self.auth_id, self.auth_token)
+        self.assertEqual(provider.auth_id, self.auth_id)
+        self.assertEqual(provider.auth_token, self.auth_token)
+        self.assertEqual(provider.API, "https://api.example.com")
+        self.assertEqual(provider.content_type, TYPE_FORM)
+        self.assertTrue(provider.decode_response)
+        self.assertEqual(provider.verify_ssl, "auto")  # Default verify_ssl should be "auto"
+        self.assertEqual(provider._zone_map, {})  # Should initialize empty zone map
+
+    def test_init_with_logger(self):
+        """Test SimpleProvider initialization with logger"""
+        logger = MagicMock()
+        provider = _TestableSimpleProvider(self.auth_id, self.auth_token, logger=logger)
+        logger.getChild.assert_called_once_with("_TestableSimpleProvider")
+        self.assertIsNotNone(provider.logger)
+
+    def test_init_with_options(self):
+        """Test SimpleProvider initialization with additional options"""
+        options = {"debug": True, "timeout": 30}
+        provider = _TestableSimpleProvider(self.auth_id, self.auth_token, verify_ssl=False, **options)
+        self.assertEqual(provider.options, options)
+        self.assertFalse(provider.verify_ssl)  # Should respect verify_ssl parameter
+
+    def test_init_with_verify_ssl_string(self):
+        """Test SimpleProvider initialization with verify_ssl as string"""
+        provider = _TestableSimpleProvider(self.auth_id, self.auth_token, verify_ssl="/path/to/cert")
+        self.assertEqual(provider.verify_ssl, "/path/to/cert")
+
+    def test_init_with_verify_ssl_false(self):
+        """Test SimpleProvider initialization with verify_ssl as False"""
+        provider = _TestableSimpleProvider(self.auth_id, self.auth_token, verify_ssl=False)
+        self.assertFalse(provider.verify_ssl)
+
+    def test_init_with_verify_ssl_truthy_value(self):
+        """Test SimpleProvider initialization with verify_ssl as truthy value"""
+        provider = _TestableSimpleProvider(self.auth_id, self.auth_token, verify_ssl=1)  # type: ignore
+        self.assertEqual(provider.verify_ssl, 1)  # Should preserve the exact value
+
+    def test_init_with_verify_ssl_falsy_value(self):
+        """Test SimpleProvider initialization with verify_ssl as falsy value"""
+        provider = _TestableSimpleProvider(self.auth_id, self.auth_token, verify_ssl=0)  # type: ignore
+        self.assertEqual(provider.verify_ssl, 0)  # Should preserve the exact value
+
+    def test_validate_missing_id(self):
+        """Test _validate method with missing auth_id"""
+        with self.assertRaises(ValueError) as cm:
+            _TestableSimpleProvider(None, self.auth_token)  # type: ignore
+        self.assertIn("id must be configured", str(cm.exception))
+
+    def test_validate_missing_token(self):
+        """Test _validate method with missing auth_token"""
+        with self.assertRaises(ValueError) as cm:
+            _TestableSimpleProvider(self.auth_id, None)  # type: ignore
+        self.assertIn("token must be configured", str(cm.exception))
+
+    def test_validate_empty_id(self):
+        """Test _validate method with empty auth_id"""
+        with self.assertRaises(ValueError) as cm:
+            _TestableSimpleProvider("", self.auth_token)
+        self.assertIn("id must be configured", str(cm.exception))
+
+    def test_validate_empty_token(self):
+        """Test _validate method with empty auth_token"""
+        with self.assertRaises(ValueError) as cm:
+            _TestableSimpleProvider(self.auth_id, "")
+        self.assertIn("token must be configured", str(cm.exception))
+
+    def test_set_proxy(self):
+        """Test set_proxy method"""
+        provider = _TestableSimpleProvider(self.auth_id, self.auth_token)
+        proxy_str = "http://proxy.example.com:8080"
+
+        result = provider.set_proxy(proxy_str)
+
+        self.assertEqual(provider.proxy, proxy_str)
+        self.assertIs(result, provider)  # Should return self for chaining
+
+    def test_set_proxy_none(self):
+        """Test set_proxy method with None"""
+        provider = _TestableSimpleProvider(self.auth_id, self.auth_token)
+
+        result = provider.set_proxy(None)
+
+        self.assertIsNone(provider.proxy)
+        self.assertIs(result, provider)
+
+    def test_encode_dict(self):
+        """Test _encode method with dictionary"""
+        params = {"key1": "value1", "key2": "value2"}
+        result = _TestableSimpleProvider._encode(params)
+
+        # Result should be URL-encoded string
+        self.assertIn("key1=value1", result)
+        self.assertIn("key2=value2", result)
+        self.assertIn("&", result)
+
+    def test_encode_list(self):
+        """Test _encode method with list"""
+        params = [("key1", "value1"), ("key2", "value2")]
+        result = _TestableSimpleProvider._encode(params)
+
+        self.assertIn("key1=value1", result)
+        self.assertIn("key2=value2", result)
+
+    def test_encode_string(self):
+        """Test _encode method with string"""
+        params = "key1=value1&key2=value2"
+        result = _TestableSimpleProvider._encode(params)
+
+        self.assertEqual(result, params)
+
+    def test_encode_none(self):
+        """Test _encode method with None"""
+        result = _TestableSimpleProvider._encode(None)
+        self.assertEqual(result, "")
+
+    def test_encode_empty_dict(self):
+        """Test _encode method with empty dictionary"""
+        result = _TestableSimpleProvider._encode({})
+        self.assertEqual(result, "")
+
+    def test_quote_basic(self):
+        """Test _quote method with basic string"""
+        data = "hello world"
+        result = _TestableSimpleProvider._quote(data)
+        self.assertEqual(result, "hello%20world")
+
+    def test_quote_with_safe_chars(self):
+        """Test _quote method with safe characters"""
+        data = "hello/world"
+        result = _TestableSimpleProvider._quote(data, safe="/")
+        self.assertEqual(result, "hello/world")
+
+    def test_quote_without_safe_chars(self):
+        """Test _quote method without safe characters"""
+        data = "hello/world"
+        result = _TestableSimpleProvider._quote(data, safe="")
+        self.assertEqual(result, "hello%2Fworld")
+
+    def test_mask_sensitive_data_basic(self):
+        """Test _mask_sensitive_data method with basic token"""
+        provider = _TestableSimpleProvider(self.auth_id, "secret123")
+        data = "url?token=secret123&other=value"
+
+        result = provider._mask_sensitive_data(data)  # type: str # type: ignore
+
+        self.assertNotIn("secret123", result)
+        self.assertIn("se***23", result)
+
+    def test_mask_sensitive_data_short_token(self):
+        """Test _mask_sensitive_data method with short token"""
+        provider = _TestableSimpleProvider(self.auth_id, "abc")
+        data = "url?token=abc&other=value"
+
+        result = provider._mask_sensitive_data(data)  # type: str # type: ignore
+
+        self.assertNotIn("abc", result)
+        self.assertIn("***", result)
+
+    def test_mask_sensitive_data_empty_data(self):
+        """Test _mask_sensitive_data method with empty data"""
+        provider = _TestableSimpleProvider(self.auth_id, self.auth_token)
+
+        result = provider._mask_sensitive_data("")
+
+        self.assertEqual(result, "")
+
+    def test_mask_sensitive_data_none_data(self):
+        """Test _mask_sensitive_data method with None data"""
+        provider = _TestableSimpleProvider(self.auth_id, self.auth_token)
+
+        result = provider._mask_sensitive_data(None)
+
+        self.assertIsNone(result)
+
+    def test_mask_sensitive_data_no_token(self):
+        """Test _mask_sensitive_data method with no token"""
+        # Create provider normally first, then modify auth_token
+        provider = _TestableSimpleProvider(self.auth_id, self.auth_token)
+        provider.auth_token = ""  # Override after init
+        data = "url?token=secret123&other=value"
+
+        result = provider._mask_sensitive_data(data)
+
+        self.assertEqual(result, data)  # Should be unchanged
+
+    def test_mask_sensitive_data_long_token(self):
+        """Test _mask_sensitive_data method with long token"""
+        provider = _TestableSimpleProvider(self.auth_id, "verylongsecrettoken123")
+        data = "url?token=verylongsecrettoken123&other=value"
+
+        result = provider._mask_sensitive_data(data)  # type: str # type: ignore
+        self.assertNotIn("verylongsecrettoken123", result)
+        self.assertIn("ve***23", result)
+
+    def test_mask_sensitive_data_url_encoded(self):
+        """Test _mask_sensitive_data method with URL encoded sensitive data"""
+        from ddns.provider._base import quote
+
+        provider = _TestableSimpleProvider("[email protected]", "secret_token_123")
+
+        # 测试URL编码的token
+        token_encoded = quote("secret_token_123", safe="")
+        id_encoded = quote("[email protected]", safe="")
+        data = "url?token={}&id={}&other=value".format(token_encoded, id_encoded)
+
+        result = provider._mask_sensitive_data(data)
+        self.assertIsNotNone(result)
+        self.assertIsInstance(result, str)
+
+        # Cast result to str for type checking
+        result_str = str(result)
+
+        # 验证原始敏感token信息不泄露
+        self.assertNotIn("secret_token_123", result_str)
+        # 验证URL编码的敏感token信息也不泄露
+        self.assertNotIn(token_encoded, result_str)
+        # 验证包含打码信息
+        self.assertIn("se***23", result_str)
+
+        # auth_id 不再被打码,应该保持原样(URL编码形式)
+        self.assertIn(id_encoded, result_str)  # user%40example.com
+
+    def test_mask_sensitive_data_bytes_url_encoded(self):
+        """Test _mask_sensitive_data method with bytes containing URL encoded data"""
+        from ddns.provider._base import quote
+
+        provider = _TestableSimpleProvider("[email protected]", "token123")
+
+        # 测试字节数据包含URL编码的敏感信息
+        token_encoded = quote("token123", safe="")
+        data = "url?token={}&data=something".format(token_encoded).encode()
+
+        result = provider._mask_sensitive_data(data)
+        self.assertIsNotNone(result)
+        self.assertIsInstance(result, bytes)
+
+        # Cast result to bytes for type checking
+        result_bytes = bytes(result) if isinstance(result, bytes) else result.encode() if result else b""
+
+        # 验证原始和URL编码的token都不泄露
+        self.assertNotIn(b"token123", result_bytes)
+        self.assertNotIn(token_encoded.encode(), result_bytes)
+        # 验证包含打码信息
+        self.assertIn(b"to***23", result_bytes)
+
+    def test_set_record_abstract_method(self):
+        """Test that set_record is implemented in test class"""
+        provider = _TestableSimpleProvider(self.auth_id, self.auth_token)
+
+        result = provider.set_record("example.com", "192.168.1.1")
+
+        self.assertTrue(result)
+
+
+class _TestableSimpleProviderWithNoAPI(SimpleProvider):
+    """Test implementation without API defined"""
+
+    def set_record(self, domain, value, record_type="A", ttl=None, line=None, **extra):
+        return True
+
+
+class _TestableSimpleProviderValidation(BaseProviderTestCase):
+    """Additional validation tests for SimpleProvider"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        super(_TestableSimpleProviderValidation, self).setUp()
+
+    def test_validate_missing_api(self):
+        """Test _validate method when API is not defined"""
+        with self.assertRaises(ValueError) as cm:
+            _TestableSimpleProviderWithNoAPI(self.auth_id, self.auth_token)
+        self.assertIn("API endpoint must be defined", str(cm.exception))
+        self.assertIn("_TestableSimpleProviderWithNoAPI", str(cm.exception))
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 544 - 0
tests/test_provider_tencentcloud.py

@@ -0,0 +1,544 @@
+# coding=utf-8
+"""
+Unit tests for TencentCloudProvider
+腾讯云 DNSPod 提供商单元测试
+
+@author: NewFuture
+"""
+
+from base_test import BaseProviderTestCase, unittest, patch, MagicMock
+from ddns.provider.tencentcloud import TencentCloudProvider
+
+
+class TestTencentCloudProvider(BaseProviderTestCase):
+    """Test TencentCloudProvider functionality"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        super(TestTencentCloudProvider, self).setUp()
+        self.provider = TencentCloudProvider(self.auth_id, self.auth_token)
+        self.logger = self.mock_logger(self.provider)
+
+    def test_init(self):
+        """Test provider initialization"""
+        self.assertProviderInitialized(self.provider)
+        self.assertEqual(self.provider.service, "dnspod")
+        self.assertEqual(self.provider.version_date, "2021-03-23")
+        self.assertEqual(self.provider.API, "https://dnspod.tencentcloudapi.com")
+        self.assertEqual(self.provider.content_type, "application/json")
+
+    def test_validate_success(self):
+        """Test successful validation"""
+        # Should not raise any exception
+        self.provider._validate()
+
+    def test_validate_missing_auth_id(self):
+        """Test validation with missing auth_id"""
+        with self.assertRaises(ValueError) as context:
+            TencentCloudProvider("", self.auth_token, self.logger)
+        self.assertIn("id", str(context.exception))
+
+    def test_validate_missing_auth_token(self):
+        """Test validation with missing auth_token"""
+        with self.assertRaises(ValueError) as context:
+            TencentCloudProvider(self.auth_id, "", self.logger)
+        self.assertIn("token", str(context.exception))
+
+    @patch("ddns.provider.tencentcloud.strftime")
+    @patch("ddns.provider.tencentcloud.time")
+    @patch.object(TencentCloudProvider, "_http")
+    def test_sign_tc3(self, mock_http, mock_time, mock_strftime):
+        """Test TC3 signature generation"""
+        mock_time.return_value = 1609459200  # 2021-01-01
+        mock_strftime.return_value = "2021-01-01"
+
+        self.provider._request("DescribeDomains")
+
+        self.assertTrue(mock_http.called)
+        call_args = mock_http.call_args[1]
+        headers = call_args.get("headers", {})
+        authorization = headers.get("authorization")
+
+        self.assertIn("TC3-HMAC-SHA256", authorization)
+        self.assertIn("Credential=test_id/2021-01-01/dnspod/tc3_request", authorization)
+        self.assertIn("SignedHeaders=", authorization)
+        self.assertIn("content-type", authorization)
+        self.assertIn("host", authorization)
+        self.assertIn("Signature=", authorization)
+        self.assertIn(self.auth_id, authorization)
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_query_zone_id_success(self, mock_http):
+        """Test successful zone ID query"""
+        domain = "example.com"
+        expected_domain_id = 12345678
+
+        mock_http.return_value = {
+            "Response": {"DomainInfo": {"Domain": domain, "DomainId": expected_domain_id, "Status": "enable"}}
+        }
+
+        zone_id = self.provider._query_zone_id(domain)
+        self.assertEqual(zone_id, str(expected_domain_id))
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_query_zone_id_not_found(self, mock_http):
+        """Test zone ID query when domain not found"""
+        domain = "nonexistent.com"
+
+        mock_http.return_value = {
+            "Response": {
+                "Error": {
+                    "Code": "InvalidParameterValue.DomainNotExists",
+                    "Message": "当前域名有误,请返回重新操作。",
+                }
+            }
+        }
+
+        zone_id = self.provider._query_zone_id(domain)
+        self.assertIsNone(zone_id)
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_query_zone_id_invalid_response(self, mock_http):
+        """Test zone ID query with invalid response format"""
+        domain = "example.com"
+
+        mock_http.return_value = {"Response": {}}
+
+        zone_id = self.provider._query_zone_id(domain)
+        self.assertIsNone(zone_id)
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_query_record_found(self, mock_http):
+        """Test successful record query"""
+        mock_http.return_value = {
+            "Response": {
+                "RecordList": [
+                    {"RecordId": 123456, "Name": "www", "Type": "A", "Value": "1.2.3.4", "Line": "默认", "TTL": 600}
+                ]
+            }
+        }
+
+        record = self.provider._query_record("12345678", "www", "example.com", "A", None, {})
+
+        self.assertIsNotNone(record)
+        if record:  # Type narrowing for mypy
+            self.assertEqual(record["RecordId"], 123456)
+            self.assertEqual(record["Name"], "www")
+            self.assertEqual(record["Type"], "A")
+
+        # Verify HTTP call was made correctly
+        mock_http.assert_called_once()
+        call_args = mock_http.call_args
+        self.assertEqual(call_args[0][0], "POST")  # method
+        self.assertEqual(call_args[0][1], "/")  # path
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_query_record_not_found(self, mock_http):
+        """Test record query when record not found"""
+        mock_http.return_value = {"Response": {"RecordList": []}}
+
+        record = self.provider._query_record(
+            "12345678", "www", "example.com", "A", None, {}
+        )  # type: dict # type: ignore
+
+        self.assertIsNone(record)
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_query_record_root_domain(self, mock_http):
+        """Test record query for root domain (@)"""
+        mock_http.return_value = {
+            "Response": {"RecordList": [{"RecordId": 123456, "Name": "@", "Type": "A", "Value": "1.2.3.4"}]}
+        }
+
+        record = self.provider._query_record(
+            "12345678", "@", "example.com", "A", None, {}
+        )  # type: dict # type: ignore
+
+        self.assertIsNotNone(record)
+        self.assertEqual(record["Name"], "@")
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_create_record_success(self, mock_http):
+        """Test successful record creation"""
+        mock_http.return_value = {"Response": {"RecordId": 789012}}
+
+        result = self.provider._create_record("12345678", "www", "example.com", "1.2.3.4", "A", 600, None, {})
+
+        self.assertTrue(result)
+        # Verify HTTP call was made
+        mock_http.assert_called_once()
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_create_record_root_domain(self, mock_http):
+        """Test record creation for root domain"""
+        mock_http.return_value = {"Response": {"RecordId": 789012}}
+
+        result = self.provider._create_record("12345678", "@", "example.com", "1.2.3.4", "A", None, None, {})
+
+        self.assertTrue(result)
+        # Verify HTTP call was made
+        mock_http.assert_called_once()
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_create_record_with_mx(self, mock_http):
+        """Test record creation with MX priority"""
+        mock_http.return_value = {"Response": {"RecordId": 789012}}
+
+        result = self.provider._create_record(
+            "12345678", "mail", "example.com", "mail.example.com", "MX", None, None, {"MX": 10}
+        )
+
+        self.assertTrue(result)
+        # Verify HTTP call was made
+        mock_http.assert_called_once()
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_create_record_failure(self, mock_http):
+        """Test record creation failure"""
+        mock_http.return_value = {"Response": {}}  # No RecordId in response
+
+        result = self.provider._create_record("12345678", "www", "example.com", "1.2.3.4", "A", None, None, {})
+
+        self.assertFalse(result)
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_update_record_success(self, mock_http):
+        """Test successful record update"""
+        mock_http.return_value = {"Response": {"RecordId": 123456}}
+
+        old_record = {"RecordId": 123456, "Name": "www", "Type": "A", "Value": "1.2.3.4", "Line": "默认", "TTL": 300}
+
+        result = self.provider._update_record("12345678", old_record, "5.6.7.8", "A", 600, None, {})
+
+        self.assertTrue(result)
+        # Verify HTTP call was made
+        mock_http.assert_called_once()
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_update_record_preserve_old_values(self, mock_http):
+        """Test record update preserves old values when not specified"""
+        mock_http.return_value = {"Response": {"RecordId": 123456}}
+
+        old_record = {
+            "RecordId": 123456,
+            "Name": "www",
+            "Type": "A",
+            "Value": "1.2.3.4",
+            "Line": "电信",
+            "TTL": 300,
+            "MX": 10,
+            "Weight": 5,
+            "Remark": "Old remark",
+        }
+
+        result = self.provider._update_record("12345678", old_record, "5.6.7.8", "A", None, None, {})
+
+        self.assertTrue(result)
+        # Verify HTTP call was made
+        mock_http.assert_called_once()
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_update_record_missing_record_id(self, mock_http):
+        """Test record update with missing RecordId"""
+        mock_http.return_value = {"Response": {}}  # No RecordId in response
+        old_record = {"Name": "www", "Type": "A"}
+
+        result = self.provider._update_record("12345678", old_record, "5.6.7.8", "A", None, None, {})
+
+        self.assertFalse(result)  # Returns False because response doesn't contain RecordId
+        mock_http.assert_called_once()  # Request is still made
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_update_record_failure(self, mock_http):
+        """Test record update failure"""
+        mock_http.return_value = {"Response": {}}  # No RecordId in response
+
+        old_record = {"RecordId": 123456}
+
+        result = self.provider._update_record("12345678", old_record, "5.6.7.8", "A", None, None, {})
+
+        self.assertFalse(result)
+
+    @patch("ddns.provider.tencentcloud.strftime")
+    @patch("ddns.provider.tencentcloud.time")
+    @patch.object(TencentCloudProvider, "_http")
+    def test_request_success(self, mock_http, mock_time, mock_strftime):
+        """Test successful API request"""
+        # Mock time functions to get consistent results
+        mock_time.return_value = 1609459200
+        mock_strftime.return_value = "20210101"
+        mock_http.return_value = {"Response": {"RecordId": 123456, "RequestId": "test-request-id"}}
+
+        result = self.provider._request("DescribeRecordList", Domain="example.com")
+
+        self.assertIsNotNone(result)
+        if result:  # Type narrowing for mypy
+            self.assertEqual(result["RecordId"], 123456)
+        mock_http.assert_called_once()
+
+    @patch("ddns.provider.tencentcloud.strftime")
+    @patch("ddns.provider.tencentcloud.time")
+    @patch.object(TencentCloudProvider, "_http")
+    def test_request_api_error(self, mock_http, mock_time, mock_strftime):
+        """Test API request with error response"""
+        mock_time.return_value = 1609459200
+        mock_strftime.return_value = "20210101"
+        mock_http.return_value = {
+            "Response": {"Error": {"Code": "InvalidParameter", "Message": "Invalid domain name"}}
+        }
+
+        result = self.provider._request("DescribeRecordList", Domain="invalid")
+
+        self.assertIsNone(result)
+
+    @patch("ddns.provider.tencentcloud.strftime")
+    @patch("ddns.provider.tencentcloud.time")
+    @patch.object(TencentCloudProvider, "_http")
+    def test_request_unexpected_response(self, mock_http, mock_time, mock_strftime):
+        """Test API request with unexpected response format"""
+        mock_time.return_value = 1609459200
+        mock_strftime.return_value = "20210101"
+        mock_http.return_value = {"UnexpectedField": "value"}
+
+        result = self.provider._request("DescribeRecordList", Domain="example.com")
+
+        self.assertIsNone(result)
+
+    @patch("ddns.provider.tencentcloud.strftime")
+    @patch("ddns.provider.tencentcloud.time")
+    @patch.object(TencentCloudProvider, "_http")
+    def test_request_exception(self, mock_http, mock_time, mock_strftime):
+        """Test API request with exception"""
+        mock_time.return_value = 1609459200
+        mock_strftime.return_value = "20210101"
+        mock_http.side_effect = Exception("Network error")
+
+        # The implementation doesn't catch exceptions, so it will propagate
+        with self.assertRaises(Exception) as cm:
+            self.provider._request("DescribeRecordList", Domain="example.com")
+
+        self.assertEqual(str(cm.exception), "Network error")
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_set_record_create_new(self, mock_http):
+        """Test set_record creating a new record"""
+        # Mock HTTP responses for the workflow
+        responses = [
+            # DescribeDomain response (get domain ID)
+            {"Response": {"DomainInfo": {"Domain": "example.com", "DomainId": 12345678}}},
+            # DescribeRecordList response (no existing records)
+            {"Response": {"RecordList": []}},
+            # CreateRecord response (record created successfully)
+            {"Response": {"RecordId": 123456}},
+        ]
+        mock_http.side_effect = responses
+
+        result = self.provider.set_record("www.example.com", "1.2.3.4", "A")
+
+        self.assertTrue(result)
+        self.assertEqual(mock_http.call_count, 3)
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_set_record_update_existing(self, mock_http):
+        """Test set_record updating an existing record"""
+        # Mock HTTP responses for the workflow
+        responses = [
+            # DescribeDomain response (get domain ID)
+            {"Response": {"DomainInfo": {"Domain": "example.com", "DomainId": 12345678}}},
+            # DescribeRecordList response (existing record found)
+            {
+                "Response": {
+                    "RecordList": [
+                        {
+                            "RecordId": 123456,
+                            "Name": "www",
+                            "Type": "A",
+                            "Value": "1.2.3.4",
+                            "DomainId": 12345678,
+                            "Line": "默认",
+                        }
+                    ]
+                }
+            },
+            # ModifyRecord response (record updated successfully)
+            {"Response": {"RecordId": 123456}},
+        ]
+        mock_http.side_effect = responses
+
+        result = self.provider.set_record("www.example.com", "5.6.7.8", "A")
+
+        self.assertTrue(result)
+        self.assertEqual(mock_http.call_count, 3)
+
+    @patch("ddns.provider.tencentcloud.strftime")
+    def test_sign_tc3_date_format(self, mock_strftime):
+        """Test that the TC3 signature uses the current date in credential scope"""
+        mock_strftime.return_value = "20210323"  # Mock strftime to return a specific date
+
+        method = "POST"
+        uri = "/"
+        query = ""
+        headers = {"content-type": "application/json", "host": "dnspod.tencentcloudapi.com"}
+        payload = "{}"
+        timestamp = 1609459200  # 2021-01-01
+
+        authorization = self.provider._sign_tc3(method, uri, query, headers, payload, timestamp)
+
+        # Check that the mocked date is used in the credential scope
+        self.assertIn("20210323/dnspod/tc3_request", authorization)
+
+
+class TestTencentCloudProviderIntegration(BaseProviderTestCase):
+    """Integration tests for TencentCloudProvider"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        super(TestTencentCloudProviderIntegration, self).setUp()
+        self.provider = TencentCloudProvider(self.auth_id, self.auth_token)
+        self.logger = self.mock_logger(self.provider)
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_full_domain_resolution_flow(self, mock_http):
+        """Test complete domain resolution flow"""
+        # Mock HTTP responses for the workflow
+        responses = [
+            # DescribeDomain response (get domain ID)
+            {"Response": {"DomainInfo": {"Domain": "example.com", "DomainId": 12345678}}},
+            # DescribeRecordList response (no existing records)
+            {"Response": {"RecordList": []}},
+            # CreateRecord response (record created successfully)
+            {"Response": {"RecordId": 123456}},
+        ]
+        mock_http.side_effect = responses
+
+        result = self.provider.set_record("test.example.com", "1.2.3.4", "A", ttl=600)
+
+        self.assertTrue(result)
+        self.assertEqual(mock_http.call_count, 3)
+
+        # Verify the CreateRecord call parameters
+        create_call = mock_http.call_args_list[2]
+        call_body = create_call[1]["body"]
+        self.assertIn("DomainId", call_body)
+        self.assertIn("CreateRecord", create_call[1]["headers"]["X-TC-Action"])
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_custom_domain_format(self, mock_http):
+        """Test custom domain format with ~ separator"""
+        # Mock HTTP responses
+        responses = [
+            # DescribeDomain response (get domain ID)
+            {"Response": {"DomainInfo": {"Domain": "example.com", "DomainId": 12345678}}},
+            # DescribeRecordList response (no existing records)
+            {"Response": {"RecordList": []}},
+            # CreateRecord response (record created successfully)
+            {"Response": {"RecordId": 123456}},
+        ]
+        mock_http.side_effect = responses
+
+        result = self.provider.set_record("test~example.com", "1.2.3.4", "A")
+
+        self.assertTrue(result)
+
+        # Verify the CreateRecord action was called
+        create_call = mock_http.call_args_list[2]
+        headers = create_call[1]["headers"]
+        self.assertEqual(headers["X-TC-Action"], "CreateRecord")
+
+        # Verify the body contains the right domain data
+        call_body = create_call[1]["body"]
+        self.assertIn("12345678", call_body)  # DomainId instead of domain name
+        self.assertIn("test", call_body)
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_update_existing_record(self, mock_http):
+        """Test updating an existing record"""
+        # Mock HTTP responses for the workflow
+        responses = [
+            # DescribeDomain response (get domain ID)
+            {"Response": {"DomainInfo": {"Domain": "example.com", "DomainId": 12345678}}},
+            # DescribeRecordList response (existing record found)
+            {
+                "Response": {
+                    "RecordList": [
+                        {
+                            "RecordId": 12345,
+                            "Name": "test",
+                            "Type": "A",
+                            "Value": "1.2.3.4",
+                            "DomainId": 12345678,
+                            "Line": "默认",
+                        }
+                    ]
+                }
+            },
+            # ModifyRecord response (record updated successfully)
+            {"Response": {"RecordId": 12345}},
+        ]
+        mock_http.side_effect = responses
+
+        result = self.provider.set_record("test.example.com", "5.6.7.8", "A", ttl=300)
+
+        self.assertTrue(result)
+        self.assertEqual(mock_http.call_count, 3)
+
+        # Verify the ModifyRecord call
+        modify_call = mock_http.call_args_list[2]
+        self.assertIn("ModifyRecord", modify_call[1]["headers"]["X-TC-Action"])
+
+    @patch.object(TencentCloudProvider, "_http")
+    def test_api_error_handling(self, mock_http):
+        """Test API error handling"""
+        # Mock API error response for DescribeDomain
+        mock_http.return_value = {
+            "Response": {"Error": {"Code": "InvalidParameter", "Message": "Invalid domain name"}}
+        }
+
+        # This should return False because zone_id cannot be resolved
+        result = self.provider.set_record("test.example.com", "1.2.3.4", "A")
+        self.assertFalse(result)
+        # Two calls are made: split domain name first, then DescribeDomain for main domain
+        self.assertGreater(mock_http.call_count, 0)
+
+
+class TestTencentCloudProviderRealRequest(BaseProviderTestCase):
+    """TencentCloud Provider 真实请求测试类"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        super(TestTencentCloudProviderRealRequest, self).setUp()
+
+    def test_auth_failure_real_request(self):
+        """Test authentication failure with real API request"""
+        # 使用无效的认证信息创建 provider
+        invalid_provider = TencentCloudProvider("invalid_id", "invalid_token")
+
+        # Mock logger to capture error logs
+        invalid_provider.logger = MagicMock()
+
+        # 尝试查询域名信息,应该返回认证失败
+        result = invalid_provider._query_zone_id("example.com")
+
+        # 认证失败时应该返回 None (因为 API 会返回错误)
+        self.assertIsNone(result)
+
+        # 验证错误日志被记录
+        # 应该有错误日志调用,因为 API 返回认证错误
+        self.assertGreaterEqual(invalid_provider.logger.error.call_count, 1)
+
+        # 检查日志内容包含认证相关的错误信息
+        error_calls = invalid_provider.logger.error.call_args_list
+        logged_messages = [str(call) for call in error_calls]
+
+        # 至少有一个日志应该包含腾讯云 API 错误信息
+        has_auth_error = any(
+            "tencentcloud api error" in msg.lower() or "authfailure" in msg.lower() or "unauthorized" in msg.lower()
+            for msg in logged_messages
+        )
+        self.assertTrue(
+            has_auth_error, "Expected TencentCloud authentication error in logs: {0}".format(logged_messages)
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 597 - 0
tests/test_util_http.py

@@ -0,0 +1,597 @@
+# coding=utf-8
+"""
+测试 ddns.util.http 模块
+Test ddns.util.http module
+"""
+
+from __future__ import unicode_literals
+import unittest
+import ssl
+import sys
+from base_test import MagicMock, patch
+from ddns.util.http import (
+    HTTPException,
+    send_http_request,
+    HttpResponse,
+    _create_connection,
+    _load_system_ca_certs,
+    _close_connection,
+    _build_redirect_url,
+    _decode_response_body,
+)
+
+# Python 2/3 compatibility
+if sys.version_info[0] == 2:  # python 2
+    text_type = unicode  # noqa: F821
+    binary_type = str
+else:
+    text_type = str
+    binary_type = bytes
+
+
+def to_bytes(s, encoding="utf-8"):
+    if isinstance(s, text_type):
+        return s.encode(encoding)
+    return s
+
+
+def to_unicode(s, encoding="utf-8"):
+    if isinstance(s, binary_type):
+        return s.decode(encoding)
+    return s
+
+
+def byte_string(s):
+    if isinstance(s, text_type):
+        return s.encode("utf-8")
+    return s
+
+
+class TestHttpResponse(unittest.TestCase):
+    """测试 HttpResponse 类"""
+
+    def test_init(self):
+        """测试初始化HttpResponse对象"""
+        headers = [("Content-Type", "application/json"), ("Content-Length", "100")]
+        response = HttpResponse(200, "OK", headers, '{"test": true}')
+
+        self.assertEqual(response.status, 200)
+        self.assertEqual(response.reason, "OK")
+        self.assertEqual(response.headers, headers)
+        self.assertEqual(response.body, '{"test": true}')
+
+    def test_get_header_case_insensitive(self):
+        """测试get_header方法不区分大小写"""
+        headers = [("Content-Type", "application/json"), ("Content-Length", "100")]
+        response = HttpResponse(200, "OK", headers, "test")
+
+        self.assertEqual(response.get_header("content-type"), "application/json")
+        self.assertEqual(response.get_header("Content-Type"), "application/json")
+        self.assertEqual(response.get_header("CONTENT-TYPE"), "application/json")
+        self.assertEqual(response.get_header("content-length"), "100")
+
+    def test_get_header_not_found(self):
+        """测试get_header方法找不到头部时的默认值"""
+        headers = [("Content-Type", "application/json")]
+        response = HttpResponse(200, "OK", headers, "test")
+
+        self.assertIsNone(response.get_header("Authorization"))
+        self.assertEqual(response.get_header("Authorization", "default"), "default")
+
+    def test_get_header_first_match(self):
+        """测试get_header方法返回第一个匹配的头部"""
+        headers = [("Set-Cookie", "session=abc"), ("Set-Cookie", "token=xyz")]
+        response = HttpResponse(200, "OK", headers, "test")
+
+        self.assertEqual(response.get_header("Set-Cookie"), "session=abc")
+
+
+class TestCreateConnection(unittest.TestCase):
+    """测试 _create_connection 函数"""
+
+    @patch("ddns.util.http.HTTPConnection")
+    def test_create_http_connection(self, mock_http_conn):
+        """测试创建HTTP连接"""
+        mock_conn = MagicMock()
+        mock_http_conn.return_value = mock_conn
+
+        result = _create_connection("example.com", 80, False, None, True)
+
+        self.assertEqual(result, mock_conn)
+        mock_http_conn.assert_called_once_with("example.com", 80)
+        mock_conn.set_tunnel.assert_not_called()
+
+    @patch("ddns.util.http.HTTPSConnection")
+    def test_create_https_connection_default_ssl(self, mock_https_conn):
+        """测试创建HTTPS连接 - 默认SSL验证"""
+        mock_conn = MagicMock()
+        mock_https_conn.return_value = mock_conn
+
+        with patch("ddns.util.http._load_system_ca_certs") as mock_load_ca:
+            result = _create_connection("example.com", 443, True, None, True)
+
+        self.assertEqual(result, mock_conn)
+        mock_https_conn.assert_called_once()
+        mock_load_ca.assert_called_once()
+
+    @patch("ddns.util.http.HTTPSConnection")
+    @patch("ddns.util.http.ssl.create_default_context")
+    def test_create_https_connection_no_ssl_verify(self, mock_ssl_context, mock_https_conn):
+        """测试创建HTTPS连接 - 禁用SSL验证"""
+        mock_context = MagicMock()
+        mock_ssl_context.return_value = mock_context
+        mock_conn = MagicMock()
+        mock_https_conn.return_value = mock_conn
+
+        result = _create_connection("example.com", 443, True, None, False)
+
+        self.assertEqual(result, mock_conn)
+        mock_ssl_context.assert_called_once()
+        self.assertFalse(mock_context.check_hostname)
+        self.assertEqual(mock_context.verify_mode, ssl.CERT_NONE)
+
+    @patch("ddns.util.http.HTTPConnection")
+    def test_create_connection_with_proxy(self, mock_http_conn):
+        """测试创建带代理的连接"""
+        mock_conn = MagicMock()
+        mock_http_conn.return_value = mock_conn
+
+        result = _create_connection("example.com", 80, False, "proxy.example.com:8080", True)
+
+        self.assertEqual(result, mock_conn)
+        mock_http_conn.assert_called_once_with("proxy.example.com:8080", 80)
+        mock_conn.set_tunnel.assert_called_once_with("example.com", 80)
+
+    @patch("ddns.util.http.HTTPSConnection")
+    @patch("ddns.util.http.ssl.create_default_context")
+    def test_create_https_connection_custom_ca_success(self, mock_ssl_context, mock_https_conn):
+        """测试创建HTTPS连接 - 自定义CA证书成功"""
+        mock_context = MagicMock()
+        mock_ssl_context.return_value = mock_context
+        mock_conn = MagicMock()
+        mock_https_conn.return_value = mock_conn
+
+        result = _create_connection("example.com", 443, True, None, "/path/to/ca.pem")
+
+        self.assertEqual(result, mock_conn)
+        mock_context.load_verify_locations.assert_called_once_with("/path/to/ca.pem")
+
+    @patch("ddns.util.http.HTTPSConnection")
+    @patch("ddns.util.http.logger")
+    def test_create_https_connection_custom_ca_failure(self, mock_logger, mock_https_conn):
+        """测试创建HTTPS连接 - 自定义CA证书失败"""
+        mock_conn = MagicMock()
+        mock_https_conn.return_value = mock_conn
+
+        result = _create_connection("example.com", 443, True, None, "/nonexistent/ca.pem")
+
+        self.assertEqual(result, mock_conn)
+        mock_logger.error.assert_called_once()
+
+
+class TestLoadSystemCaCerts(unittest.TestCase):
+    """测试 _load_system_ca_certs 函数"""
+
+    @patch("ddns.util.http.os.path.isfile")
+    def test_load_ca_certs_success(self, mock_isfile):
+        """测试成功加载CA证书"""
+        mock_context = MagicMock()
+        mock_isfile.return_value = True
+
+        _load_system_ca_certs(mock_context)
+
+        # 验证至少尝试加载了一些证书路径
+        self.assertTrue(mock_context.load_verify_locations.called)
+
+    @patch("ddns.util.http.os.path.isfile")
+    def test_load_ca_certs_no_files(self, mock_isfile):
+        """测试没有找到CA证书文件"""
+        mock_context = MagicMock()
+        mock_isfile.return_value = False
+
+        _load_system_ca_certs(mock_context)
+
+        # 没有文件存在时不应该调用加载方法
+        mock_context.load_verify_locations.assert_not_called()
+
+    @patch("ddns.util.http.os.path.isfile")
+    @patch("ddns.util.http.logger")
+    def test_load_ca_certs_partial_failure(self, mock_logger, mock_isfile):
+        """测试部分CA证书加载失败"""
+        mock_context = MagicMock()
+        mock_isfile.return_value = True
+        # 模拟第一次成功,第二次失败
+        mock_context.load_verify_locations.side_effect = [None, Exception("Load failed"), None]
+
+        _load_system_ca_certs(mock_context)
+
+        # 应该继续尝试加载其他证书
+        self.assertTrue(mock_context.load_verify_locations.call_count > 1)
+
+
+class TestCloseConnection(unittest.TestCase):
+    """测试 _close_connection 函数"""
+
+    def test_close_connection_success(self):
+        """测试成功关闭连接"""
+        mock_conn = MagicMock()
+
+        _close_connection(mock_conn)
+
+        mock_conn.close.assert_called_once()
+
+    @patch("ddns.util.http.logger")
+    def test_close_connection_failure(self, mock_logger):
+        """测试关闭连接失败"""
+        mock_conn = MagicMock()
+        mock_conn.close.side_effect = Exception("Close failed")
+
+        _close_connection(mock_conn)
+
+        mock_conn.close.assert_called_once()
+        mock_logger.warning.assert_called_once()
+
+
+class TestBuildRedirectUrl(unittest.TestCase):
+    """测试 _build_redirect_url 函数"""
+
+    def test_absolute_url(self):
+        """测试绝对URL重定向"""
+        result = _build_redirect_url("http://new.example.com/api", "http://old.example.com", "/old")
+        self.assertEqual(result, "http://new.example.com/api")
+
+    def test_absolute_path(self):
+        """测试绝对路径重定向"""
+        result = _build_redirect_url("/newpath", "http://example.com", "/oldpath")
+        self.assertEqual(result, "http://example.com/newpath")
+
+    def test_relative_path(self):
+        """测试相对路径重定向"""
+        result = _build_redirect_url("newfile.html", "http://example.com", "/dir/oldfile.html")
+        self.assertEqual(result, "http://example.com/dir/newfile.html")
+
+    def test_relative_path_root(self):
+        """测试根目录相对路径重定向"""
+        result = _build_redirect_url("newfile.html", "http://example.com", "/oldfile.html")
+        self.assertEqual(result, "http://example.com/newfile.html")
+
+    def test_relative_path_no_slash(self):
+        """测试无斜杠路径的相对重定向"""
+        result = _build_redirect_url("newfile.html", "http://example.com", "oldfile.html")
+        self.assertEqual(result, "http://example.com/newfile.html")
+
+
+class TestDecodeResponseBody(unittest.TestCase):
+    """测试 _decode_response_body 函数"""
+
+    def test_utf8_decoding(self):
+        """测试UTF-8解码"""
+        raw_body = to_bytes("中文测试", "utf-8")
+        result = _decode_response_body(raw_body, "text/html; charset=utf-8")
+        self.assertEqual(result, "中文测试")
+
+    def test_gbk_decoding(self):
+        """测试GBK解码"""
+        raw_body = to_bytes("中文测试", "gbk")
+        result = _decode_response_body(raw_body, "text/html; charset=gbk")
+        self.assertEqual(result, "中文测试")
+
+    def test_gb2312_alias(self):
+        """测试GB2312别名映射到GBK"""
+        raw_body = to_bytes("中文测试", "gbk")
+        result = _decode_response_body(raw_body, "text/html; charset=gb2312")
+        self.assertEqual(result, "中文测试")
+
+    def test_iso_8859_1_alias(self):
+        """测试ISO-8859-1别名映射到latin-1"""
+        raw_body = to_bytes("test", "latin-1")
+        result = _decode_response_body(raw_body, "text/html; charset=iso-8859-1")
+        self.assertEqual(result, "test")
+
+    def test_no_charset_fallback_to_utf8(self):
+        """测试没有charset时默认使用UTF-8"""
+        raw_body = to_bytes("test", "utf-8")
+        result = _decode_response_body(raw_body, "text/html")
+        self.assertEqual(result, "test")
+
+    def test_no_content_type(self):
+        """测试没有Content-Type时使用UTF-8"""
+        raw_body = to_bytes("test", "utf-8")
+        result = _decode_response_body(raw_body, None)
+        self.assertEqual(result, "test")
+
+    def test_empty_body(self):
+        """测试空响应体"""
+        result = _decode_response_body(byte_string(""), "text/html")
+        self.assertEqual(result, "")
+
+    def test_invalid_encoding_fallback(self):
+        """测试无效编码时的后备机制"""
+        raw_body = to_bytes("中文测试", "utf-8")
+        # 指定一个无效的编码
+        result = _decode_response_body(raw_body, "text/html; charset=invalid-encoding")
+        self.assertEqual(result, "中文测试")  # 应该回退到UTF-8
+
+    def test_malformed_charset(self):
+        """测试格式错误的charset"""
+        raw_body = to_bytes("test", "utf-8")
+        result = _decode_response_body(raw_body, "text/html; charset=")
+        self.assertEqual(result, "test")
+
+
+class TestSendHttpRequest(unittest.TestCase):
+    """测试 send_http_request 函数"""
+
+    @patch("ddns.util.http._create_connection")
+    @patch("ddns.util.http._close_connection")
+    def test_http_request_success(self, mock_close, mock_create):
+        """测试HTTP请求成功"""
+        mock_conn = MagicMock()
+        mock_response = MagicMock()
+        mock_response.status = 200
+        mock_response.reason = "OK"
+        mock_response.getheader.return_value = "application/json; charset=utf-8"
+        mock_response.getheaders.return_value = [("Content-Type", "application/json; charset=utf-8")]
+        mock_response.read.return_value = byte_string('{"success": true}')
+        mock_conn.getresponse.return_value = mock_response
+        mock_create.return_value = mock_conn
+
+        result = send_http_request("GET", "http://example.com/api")
+
+        # 验证返回的是HttpResponse对象
+        self.assertIsInstance(result, HttpResponse)
+        self.assertEqual(result.status, 200)
+        self.assertEqual(result.reason, "OK")
+        self.assertEqual(result.body, '{"success": true}')
+        self.assertEqual(result.headers, [("Content-Type", "application/json; charset=utf-8")])
+
+        mock_create.assert_called_once_with("example.com", None, False, None, True)
+        mock_conn.request.assert_called_once_with("GET", "/api", None, {})
+        mock_close.assert_called_once_with(mock_conn)
+
+    @patch("ddns.util.http._create_connection")
+    @patch("ddns.util.http._close_connection")
+    def test_https_request_success(self, mock_close, mock_create):
+        """测试HTTPS请求成功"""
+        mock_conn = MagicMock()
+        mock_response = MagicMock()
+        mock_response.status = 200
+        mock_response.reason = "OK"
+        mock_response.getheader.return_value = "application/json"
+        mock_response.getheaders.return_value = [("Content-Type", "application/json")]
+        mock_response.read.return_value = byte_string('{"secure": true}')
+        mock_conn.getresponse.return_value = mock_response
+        mock_create.return_value = mock_conn
+
+        result = send_http_request("GET", "https://secure.example.com/api")
+
+        self.assertIsInstance(result, HttpResponse)
+        self.assertEqual(result.status, 200)
+        self.assertEqual(result.body, '{"secure": true}')
+        mock_create.assert_called_once_with("secure.example.com", None, True, None, True)
+
+    @patch("ddns.util.http._create_connection")
+    @patch("ddns.util.http._close_connection")
+    def test_request_with_body_and_headers(self, mock_close, mock_create):
+        """测试带请求体和头的请求"""
+        mock_conn = MagicMock()
+        mock_response = MagicMock()
+        mock_response.status = 201
+        mock_response.reason = "Created"
+        mock_response.getheader.return_value = "application/json"
+        mock_response.getheaders.return_value = [("Content-Type", "application/json")]
+        mock_response.read.return_value = byte_string('{"created": true}')
+        mock_conn.getresponse.return_value = mock_response
+        mock_create.return_value = mock_conn
+
+        headers = {"Content-Type": "application/json"}
+        body = '{"data": "test"}'
+
+        result = send_http_request("POST", "http://example.com/api", body=body, headers=headers)
+
+        self.assertIsInstance(result, HttpResponse)
+        self.assertEqual(result.status, 201)
+        self.assertEqual(result.body, '{"created": true}')
+        mock_conn.request.assert_called_once_with("POST", "/api", body, headers)
+
+    @patch("ddns.util.http._create_connection")
+    @patch("ddns.util.http._close_connection")
+    def test_request_with_query_params(self, mock_close, mock_create):
+        """测试带查询参数的请求"""
+        mock_conn = MagicMock()
+        mock_response = MagicMock()
+        mock_response.status = 200
+        mock_response.reason = "OK"
+        mock_response.getheader.return_value = None
+        mock_response.getheaders.return_value = []
+        mock_response.read.return_value = byte_string('{"query": true}')
+        mock_conn.getresponse.return_value = mock_response
+        mock_create.return_value = mock_conn
+
+        result = send_http_request("GET", "http://example.com/api?param1=value1&param2=value2")
+
+        self.assertIsInstance(result, HttpResponse)
+        self.assertEqual(result.body, '{"query": true}')
+        mock_conn.request.assert_called_once_with("GET", "/api?param1=value1&param2=value2", None, {})
+
+    @patch("ddns.util.http._create_connection")
+    @patch("ddns.util.http._close_connection")
+    def test_http_error_response(self, mock_close, mock_create):
+        """测试HTTP错误响应"""
+        mock_conn = MagicMock()
+        mock_response = MagicMock()
+        mock_response.status = 404
+        mock_response.reason = "Not Found"
+        mock_response.getheader.return_value = None
+        mock_response.getheaders.return_value = []
+        mock_response.read.return_value = byte_string('{"error": "Not found"}')
+        mock_conn.getresponse.return_value = mock_response
+        mock_create.return_value = mock_conn
+
+        result = send_http_request("GET", "http://example.com/notfound")
+
+        # 验证返回的是HttpResponse对象,不抛出异常
+        self.assertIsInstance(result, HttpResponse)
+        self.assertEqual(result.status, 404)
+        self.assertEqual(result.reason, "Not Found")
+        self.assertEqual(result.body, '{"error": "Not found"}')
+        mock_close.assert_called_once_with(mock_conn)
+
+    def test_too_many_redirects(self):
+        """测试重定向次数超限"""
+        with self.assertRaises(HTTPException) as context:
+            send_http_request("GET", "http://example.com/api", max_redirects=0)
+
+        self.assertEqual(str(context.exception), "Too many redirects")
+
+    @patch("ddns.util.http._create_connection")
+    @patch("ddns.util.http._close_connection")
+    @patch("ddns.util.http.logger")
+    def test_ssl_auto_fallback(self, mock_logger, mock_close, mock_create):
+        """测试SSL auto模式的自动降级"""
+        # 第一次连接SSL失败
+        mock_conn1 = MagicMock()
+        mock_conn1.request.side_effect = ssl.SSLError("certificate verify failed")
+
+        # 第二次连接成功
+        mock_conn2 = MagicMock()
+        mock_response = MagicMock()
+        mock_response.status = 200
+        mock_response.reason = "OK"
+        mock_response.getheader.return_value = None
+        mock_response.getheaders.return_value = []
+        mock_response.read.return_value = byte_string('{"fallback": true}')
+        mock_conn2.getresponse.return_value = mock_response
+
+        mock_create.side_effect = [mock_conn1, mock_conn2]
+
+        result = send_http_request("GET", "https://bad-ssl.example.com/api", verify_ssl="auto")
+
+        self.assertIsInstance(result, HttpResponse)
+        self.assertEqual(result.body, '{"fallback": true}')
+        mock_logger.warning.assert_called_once()
+
+    @patch("ddns.util.http._create_connection")
+    @patch("ddns.util.http._close_connection")
+    def test_redirect_301_absolute_url(self, mock_close, mock_create):
+        """测试301重定向到绝对URL"""
+        # 第一次请求返回301重定向
+        mock_conn1 = MagicMock()
+        mock_response1 = MagicMock()
+        mock_response1.status = 301
+        mock_response1.getheader.return_value = "http://new.example.com/newapi"
+        mock_conn1.getresponse.return_value = mock_response1
+
+        # 第二次请求返回成功
+        mock_conn2 = MagicMock()
+        mock_response2 = MagicMock()
+        mock_response2.status = 200
+        mock_response2.reason = "OK"
+        mock_response2.getheader.return_value = None
+        mock_response2.getheaders.return_value = []
+        mock_response2.read.return_value = byte_string('{"redirected": true}')
+        mock_conn2.getresponse.return_value = mock_response2
+
+        mock_create.side_effect = [mock_conn1, mock_conn2]
+
+        result = send_http_request("GET", "http://old.example.com/api")
+
+        self.assertIsInstance(result, HttpResponse)
+        self.assertEqual(result.body, '{"redirected": true}')
+        # 验证第二次调用使用了新的主机名
+        second_call_args = mock_create.call_args_list[1][0]
+        self.assertEqual(second_call_args[0], "new.example.com")
+
+    @patch("ddns.util.http._create_connection")
+    @patch("ddns.util.http._close_connection")
+    def test_redirect_302_relative_path(self, mock_close, mock_create):
+        """测试302重定向到相对路径"""
+        # 第一次请求返回302重定向
+        mock_conn1 = MagicMock()
+        mock_response1 = MagicMock()
+        mock_response1.status = 302
+        mock_response1.getheader.return_value = "/newpath"
+        mock_conn1.getresponse.return_value = mock_response1
+
+        # 第二次请求返回成功
+        mock_conn2 = MagicMock()
+        mock_response2 = MagicMock()
+        mock_response2.status = 200
+        mock_response2.reason = "OK"
+        mock_response2.getheader.return_value = None
+        mock_response2.getheaders.return_value = []
+        mock_response2.read.return_value = byte_string('{"relative": true}')
+        mock_conn2.getresponse.return_value = mock_response2
+
+        mock_create.side_effect = [mock_conn1, mock_conn2]
+
+        result = send_http_request("GET", "http://example.com/oldpath")
+
+        self.assertIsInstance(result, HttpResponse)
+        self.assertEqual(result.body, '{"relative": true}')
+        # 验证第二次请求使用了正确的路径
+        mock_conn2.request.assert_called_once_with("GET", "/newpath", None, {})
+
+    @patch("ddns.util.http._create_connection")
+    @patch("ddns.util.http._close_connection")
+    def test_redirect_303_post_to_get(self, mock_close, mock_create):
+        """测试303重定向POST变为GET"""
+        # 第一次POST请求返回303重定向
+        mock_conn1 = MagicMock()
+        mock_response1 = MagicMock()
+        mock_response1.status = 303
+        mock_response1.getheader.return_value = "/result"
+        mock_conn1.getresponse.return_value = mock_response1
+
+        # 第二次GET请求返回成功
+        mock_conn2 = MagicMock()
+        mock_response2 = MagicMock()
+        mock_response2.status = 200
+        mock_response2.reason = "OK"
+        mock_response2.getheader.return_value = None
+        mock_response2.getheaders.return_value = []
+        mock_response2.read.return_value = byte_string('{"method_changed": true}')
+        mock_conn2.getresponse.return_value = mock_response2
+
+        mock_create.side_effect = [mock_conn1, mock_conn2]
+
+        result = send_http_request("POST", "http://example.com/submit", body="data")
+
+        self.assertIsInstance(result, HttpResponse)
+        self.assertEqual(result.body, '{"method_changed": true}')
+        # 验证第二次请求变为GET且没有body
+        mock_conn2.request.assert_called_once_with("GET", "/result", None, {})
+
+    @patch("ddns.util.http._create_connection")
+    @patch("ddns.util.http._close_connection")
+    @patch("ddns.util.http.logger")
+    def test_redirect_without_location_header(self, mock_logger, mock_close, mock_create):
+        """测试重定向状态码但没有Location头"""
+        # 第一次请求返回重定向但没有Location头
+        mock_conn1 = MagicMock()
+        mock_response1 = MagicMock()
+        mock_response1.status = 302
+        mock_response1.getheader.return_value = None  # 没有Location头
+        mock_conn1.getresponse.return_value = mock_response1
+
+        # 第二次请求(重定向到空字符串)
+        mock_conn2 = MagicMock()
+        mock_response2 = MagicMock()
+        mock_response2.status = 200
+        mock_response2.reason = "OK"
+        mock_response2.getheader.return_value = None
+        mock_response2.getheaders.return_value = []
+        mock_response2.read.return_value = byte_string('{"empty_location": true}')
+        mock_conn2.getresponse.return_value = mock_response2
+
+        mock_create.side_effect = [mock_conn1, mock_conn2]
+
+        result = send_http_request("GET", "http://example.com/api")
+
+        mock_logger.warning.assert_called_once()
+        self.assertIsInstance(result, HttpResponse)
+        self.assertEqual(result.body, '{"empty_location": true}')
+
+
+if __name__ == "__main__":
+    unittest.main()